contentful-hugo
Version:
Node module that pulls data from Contentful and turns it into markdown files for Hugo. Can be used with other Static Site Generators, but has some Hugo specific features.
1,442 lines (1,408 loc) • 41.5 kB
JavaScript
import Limiter from 'async-limiter';
import path, { resolve } from 'path';
import { existsSync, readFileSync } from 'node:fs';
import yaml from 'js-yaml';
import * as c12 from 'c12';
import fs from 'fs-extra';
import * as contentful from 'contentful';
import { documentToPlainTextString } from '@contentful/rich-text-plain-text-renderer';
import { MARKS, INLINES, BLOCKS } from '@contentful/rich-text-types';
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';
import * as YAML from 'json-to-pretty-yaml';
import fs$1 from 'fs';
import { fileURLToPath } from 'url';
import { copyFile, unlink } from 'node:fs/promises';
const removeLeadingAndTrailingSlashes = (string) => string.replace(/^\/+|\/+$/g, "");
const isMultilineString = (string) => {
const array = string.split(`
`);
if (array.length && array.length > 1) {
return true;
}
return false;
};
const specialEntities = {
""": '"',
"'": "'",
"&": "&",
"<": "<",
">": ">",
"ˆ": "^",
" ": " "
};
const replaceSpecialEntities = (string) => {
const checks = Object.keys(specialEntities);
let finalString = string;
for (const check of checks) {
const regex = new RegExp(`${check}`, "g");
const replacementValue = specialEntities[check];
if (typeof replacementValue === "string") {
finalString = finalString.replace(regex, replacementValue);
}
}
return finalString;
};
const characterIsWhiteSpace = (char) => /\s/.test(char);
const leadingSpaces = (string, count = 0) => {
if (characterIsWhiteSpace(string.charAt(0))) {
return leadingSpaces(string.slice(1), count + 1);
}
let removedSpaces = "";
for (let i = 0; i < count; i++) {
removedSpaces += " ";
}
return {
exists: count > 0,
newString: string,
removedSpaces,
count
};
};
const trailingSpaces = (string, count = 0) => {
const str = string.replace(/\s/g, " ");
if (characterIsWhiteSpace(str.charAt(str.length - 1))) {
return trailingSpaces(str.slice(0, -1), count + 1);
}
let removedSpaces = "";
for (let i = 0; i < count; i++) {
removedSpaces += " ";
}
return {
exists: count > 0,
newString: str,
removedSpaces,
count
};
};
const endsWith = (str, ext) => new RegExp(`${ext}$`).test(str);
const replaceBackslashesWithForwardSlashes = (input) => input.replace(/\\/g, "/");
const determineFileType = (fileName) => {
const splitStr = fileName.split(".");
const fileExtension = splitStr[splitStr.length - 1];
switch (fileExtension) {
case "js":
return "javascript";
case "ts":
return "typescript";
case "yaml":
case "yml":
return "yaml";
default:
return null;
}
};
const isValidFileExtension = (extension) => ["md", "yaml", "yml", "json"].some(
(ext) => endsWith(extension || "md", ext)
);
const isTypeConfig = (input) => {
const mappedInput = input;
if (!Array.isArray(mappedInput)) {
return false;
}
for (const item of mappedInput) {
if (typeof item.id !== "string") {
return false;
}
if (typeof item.directory !== "string") {
return false;
}
}
return true;
};
const isContentfulHugoConfig = (input) => {
const mappedInput = input;
const { contentful, singleTypes, repeatableTypes } = mappedInput;
if (!contentful) {
return false;
}
if (typeof contentful.space !== "string" || typeof contentful.environment !== "string" || typeof contentful.token !== "string") {
return false;
}
if (!isTypeConfig(singleTypes)) {
return false;
}
if (!isTypeConfig(repeatableTypes)) {
return false;
}
return true;
};
const checkContentfulSettings = (config) => {
let { contentful } = config;
if (!contentful) {
contentful = {};
}
const space = contentful.space || process.env.CONTENTFUL_SPACE || "";
const token = contentful.token || process.env.CONTENTFUL_TOKEN || "";
const previewToken = contentful.previewToken || process.env.CONTENTFUL_PREVIEW_TOKEN || "";
const environment = contentful.environment || "master";
const newConfig = {
locales: config.locales || [],
contentful: {
space,
token,
previewToken,
environment,
pageSize: contentful.pageSize
},
singleTypes: config.singleTypes || [],
repeatableTypes: config.repeatableTypes || [],
staticContent: config.staticContent || []
};
return newConfig;
};
const loadJavascriptConfigFile = async (filePath) => {
let { config } = await c12.loadConfig({
configFile: filePath
});
if (config && typeof config === "object") {
config = checkContentfulSettings(config);
if (isContentfulHugoConfig(config)) {
return config;
}
}
return null;
};
const loadYamlConfigFile = (filePath) => {
const file = readFileSync(filePath);
let configObject = yaml.load(file.toString());
if (configObject && typeof configObject === "object") {
configObject = checkContentfulSettings(configObject);
if (isContentfulHugoConfig(configObject)) {
return configObject;
}
}
return null;
};
const loadFile = async (rootDir = ".", fileName = "") => {
const filePath = path.resolve(rootDir, fileName);
if (existsSync(filePath)) {
const fileType = determineFileType(fileName);
if (fileType === "javascript" || fileType === "typescript") {
return loadJavascriptConfigFile(filePath);
}
if (fileType === "yaml") {
return loadYamlConfigFile(filePath);
}
}
return null;
};
const getResolveEntryConfigs = (data) => {
if (Array.isArray(data)) {
return data;
}
if (typeof data === "object") {
const configs = [];
Object.keys(data).forEach((key) => {
configs.push({
field: key,
resolveTo: data[key]
});
});
return configs;
}
return [];
};
const getOverrideConfigs = (data) => {
if (Array.isArray(data)) {
return data;
}
if (typeof data === "object") {
const configs = [];
Object.keys(data).forEach((key) => {
configs.push({
field: key,
options: data[key]
});
});
return configs;
}
return [];
};
const loadConfig = async (rootDir = ".", fileName = "") => {
rootDir = path.resolve(rootDir);
if (fileName) {
const result = await loadFile(rootDir, fileName);
if (result) {
const config = checkContentfulSettings(result);
return config;
}
throw new Error(`${fileName} does not exist or it is empty.`);
}
const defaultConfigNames = [
"contentful-hugo.config.ts",
"contentful-hugo.config.js",
"contentful-hugo.config.yaml",
"contentful-hugo.yaml",
"contentful-settings.yaml"
];
const tasks = [];
const configMap = {};
for (const name of defaultConfigNames) {
const file = loadFile(rootDir, name).then((result) => {
if (result) {
const conf = checkContentfulSettings(result);
configMap[name] = conf;
return;
}
configMap[name] = null;
});
tasks.push(file);
}
await Promise.all(tasks);
for (const filename of defaultConfigNames) {
if (configMap[filename]) {
console.log(`Using config at ${filename}`);
return configMap[filename];
}
}
return false;
};
const defineConfig = (config) => config;
const getEntryFields = (entry) => {
let obj = {};
if (entry.sys) {
obj = {
id: entry.sys.id,
// contentType is missing if an deleted entry get's referenced
contentType: entry.sys.contentType?.sys.id
};
}
return obj;
};
const parseField = (field) => {
if (typeof field === "object" && // eslint-disable-next-line @typescript-eslint/no-explicit-any
(field?.["en-US"] || field?.["en-us"])) {
return field?.["en-US"] ?? field?.["en-us"];
}
return field;
};
const getAssetFields = (contentfulObject) => {
const frontMatter = {
assetType: parseField(contentfulObject.fields.file)?.contentType || "",
url: parseField(contentfulObject.fields.file)?.url || "",
title: parseField(contentfulObject.fields.title) || "",
description: parseField(contentfulObject.fields.description) || "",
size: parseField(contentfulObject.fields.file)?.details.size,
width: parseField(contentfulObject.fields.file)?.details.image?.width,
height: parseField(contentfulObject.fields.file)?.details.image?.height
};
return frontMatter;
};
const mapEntry = (target) => ({
id: target.sys.id,
// contentType doesn't exist if the entry is "missing or inaccessible"
contentType: target.sys.contentType?.sys?.id
});
const mapAsset = (target) => {
const { title, description, file } = target.fields;
const { url, details, fileName, contentType } = parseField(file) ?? {
url: "",
details: {
size: 0
},
fileName: "",
contentType: ""
};
const asset = {
title: typeof title === "string" ? title : title?.["en-US"] ?? "",
description: typeof description === "string" ? description : description?.["en-US"] ?? "",
url,
fileName,
assetType: contentType,
size: details.size,
width: details.image?.width ?? null,
height: details.image?.height ?? null
};
return asset;
};
const optionsRenderNode = (parentContentType = "") => ({
[BLOCKS.HEADING_1]: (node, next) => `# ${next(node.content)}
`,
[BLOCKS.HEADING_2]: (node, next) => `## ${next(node.content)}
`,
[BLOCKS.HEADING_3]: (node, next) => `### ${next(node.content)}
`,
[BLOCKS.HEADING_4]: (node, next) => `#### ${next(node.content)}
`,
[BLOCKS.HEADING_5]: (node, next) => `##### ${next(node.content)}
`,
[BLOCKS.HEADING_6]: (node, next) => `###### ${next(node.content)}
`,
[BLOCKS.PARAGRAPH]: (node, next) => `${next(node.content)}
`,
[BLOCKS.QUOTE]: (node, next) => {
const string = next(node.content);
const lines = string.split(`
`);
let finalString = "";
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
finalString += `> ${line}
`;
}
const removeExtraSpace = finalString.substr(0, finalString.length - 6);
return `${removeExtraSpace}
`;
},
[BLOCKS.OL_LIST]: (node, next) => {
let string = ``;
for (let i = 0; i < node.content.length; i++) {
const item = node.content[i];
string += `${i + 1}. ${next(item.content)}`;
}
string = string.replace(/\n\n/g, `
`);
return `${string}
`;
},
[BLOCKS.UL_LIST]: (node, next) => {
let string = ``;
for (let i = 0; i < node.content.length; i++) {
const item = node.content[i];
string += `- ${next(item.content)}`;
}
string = string.replace(/\n\n/g, `
`);
return `${string}
`;
},
[BLOCKS.HR]: (_node, _next) => `---
`,
[BLOCKS.EMBEDDED_ASSET]: (node, _next) => {
const {
title,
description,
url,
fileName,
assetType,
size,
width,
height
} = mapAsset(node.data.target);
const handleQuotes = (string) => {
const regex = new RegExp(/"/, "g");
return string.replace(regex, '\\"');
};
return `{{< contentful-hugo/embedded-asset title="${handleQuotes(
title
)}" description="${handleQuotes(description || "") || ""}" url="${url || ""}" filename="${fileName || ""}" assetType="${assetType || ""}" size="${size || ""}" width="${width || ""}" height="${height || ""}" parentContentType="${parentContentType || ""}" >}}
`;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[BLOCKS.EMBEDDED_ENTRY]: (node, next) => {
const { id, contentType } = mapEntry(node.data.target);
return `{{< contentful-hugo/embedded-entry id="${id}" contentType="${contentType}" parentContentType="${parentContentType || ""}" >}}
`;
},
[INLINES.HYPERLINK]: (node, next) => `[${next(node.content)}](${node.data.uri})`,
[INLINES.ASSET_HYPERLINK]: (node, next) => {
const {
title,
description,
url,
fileName,
assetType,
size,
width,
height
} = mapAsset(node.data.target);
return `{{< contentful-hugo/asset-hyperlink title="${title}" description="${description || ""}" url="${url || ""}" filename="${fileName || ""}" assetType="${assetType || ""}" size="${size || ""}" width="${width || ""}" height="${height || ""}" parentContentType="${parentContentType || ""}" >}}${next(
node.content
)}{{< /contentful-hugo/asset-hyperlink >}}`;
},
[INLINES.ENTRY_HYPERLINK]: (node, next) => {
const { id, contentType } = mapEntry(node.data.target);
return `{{< contentful-hugo/entry-hyperlink id="${id}" contentType="${contentType}" parentContentType="${parentContentType || ""}" >}}${next(node.content)}{{< /contentful-hugo/entry-hyperlink >}}`;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[INLINES.EMBEDDED_ENTRY]: (node, next) => {
const { id, contentType } = mapEntry(node.data.target);
return `{{< contentful-hugo/inline-entry id="${id}" contentType="${contentType}" parentContentType="${parentContentType || ""}" >}}`;
}
});
const sanitizedMarkOutput = (input, markWrapper) => {
const leading = leadingSpaces(input);
const trailing = trailingSpaces(leading.newString);
return `${leading.removedSpaces}${markWrapper}${trailing.newString}${markWrapper}${trailing.removedSpaces}`;
};
const options = (parentContentType = "") => ({
renderMark: {
[MARKS.BOLD]: (text) => sanitizedMarkOutput(text, "**"),
[MARKS.ITALIC]: (text) => sanitizedMarkOutput(text, "_"),
[MARKS.CODE]: (text) => {
if (isMultilineString(text)) {
return `\`\`\`
${text}
\`\`\``;
}
return `\`${text}\``;
}
},
renderNode: optionsRenderNode(parentContentType)
});
const richTextToMarkdown = (document, contentType) => {
const string = documentToHtmlString(document, options(contentType));
return `
${replaceSpecialEntities(string)}`;
};
let isQuietMode = false;
let isVerboseMode = false;
var LogTypes = /* @__PURE__ */ ((LogTypes2) => {
LogTypes2[LogTypes2["log"] = 0] = "log";
LogTypes2[LogTypes2["info"] = 1] = "info";
LogTypes2[LogTypes2["warn"] = 2] = "warn";
return LogTypes2;
})(LogTypes || {});
const log = (message, type = 0 /* log */, category = 0 /* default */) => {
const emitLog = () => {
switch (type) {
case 1 /* info */:
console.info(message);
break;
case 2 /* warn */:
console.warn(message);
break;
default:
console.log(message);
break;
}
};
if (isQuietMode) {
return;
}
if (category === 1 /* verbose */ && !isVerboseMode) {
return;
}
emitLog();
};
const initLogger = (quietMode = false, verboseMode = false) => {
isQuietMode = quietMode;
isVerboseMode = verboseMode;
return log;
};
const mapDataNode = (node = {}) => {
const { target } = node;
if (target) {
if (target.sys) {
switch (target.sys.type) {
case "Entry":
return getEntryFields(target);
case "Asset":
return getAssetFields(target);
}
} else {
log(node);
}
}
return node;
};
const mapContentNode = (node = []) => {
const contentArr = [];
for (const item of node) {
contentArr.push(richTextNodes(item));
}
return contentArr;
};
const mapMarks = (node = []) => {
const markArr = [];
for (const item of node) {
markArr.push(item.type);
}
return markArr;
};
const richTextNodes = (node = {}) => {
const fieldContent = {};
for (const field of Object.keys(node)) {
const subNode = node[field];
switch (field) {
case "data": {
fieldContent[field] = mapDataNode(subNode);
break;
}
case "content": {
fieldContent[field] = mapContentNode(subNode);
break;
}
case "marks": {
fieldContent[field] = mapMarks(subNode);
break;
}
default:
fieldContent[field] = node[field];
break;
}
}
return fieldContent;
};
const getCustomFields = (appendFields, entry) => {
const fields = {};
if (typeof appendFields === "object") {
Object.keys(appendFields).forEach((key) => {
const fieldVal = appendFields[key];
switch (typeof fieldVal) {
case "function":
fields[key] = fieldVal(entry);
break;
default:
fields[key] = fieldVal;
break;
}
});
}
return fields;
};
const mapArrayField = (fieldContent) => {
if (!fieldContent.length) {
return [];
}
const array = [];
for (let i = 0; i < fieldContent.length; i++) {
const arrayNode = fieldContent[i];
switch (typeof arrayNode) {
case "object":
if (arrayNode?.sys) {
switch (arrayNode.sys.type) {
case "Asset":
array.push(getAssetFields(arrayNode));
break;
case "Entry":
array.push(getEntryFields(arrayNode));
break;
default:
array.push(arrayNode);
break;
}
} else {
array.push(arrayNode);
}
break;
default:
array.push(arrayNode);
break;
}
}
return array;
};
const mapReferenceField = (fieldContent) => {
switch (fieldContent.sys.type) {
case "Asset":
return getAssetFields(fieldContent);
case "Entry":
return getEntryFields(fieldContent);
default:
return fieldContent;
}
};
const mapRichTextField = (fieldContent) => {
const richText = [];
const fieldPlainText = documentToPlainTextString(fieldContent);
const nodes = fieldContent.content;
if (nodes && nodes.length) {
for (let i = 0; i < nodes.length; i++) {
richText.push(richTextNodes(nodes[i]));
}
}
return {
richText,
plainText: fieldPlainText
};
};
const shouldResolve = (fieldName, resolve = []) => {
for (const item of resolve) {
if (fieldName === item.field) {
return item;
}
}
return false;
};
const shouldOverride = (fieldName, overrides = []) => {
for (const item of overrides) {
if (fieldName === item.field) {
return item;
}
}
return false;
};
const resolveEntry = (entry = {}, resolvesToString = "") => {
const props = resolvesToString.split(".");
let value = entry;
for (const prop of props) {
value = value[prop];
}
return value;
};
const isDateField = (input) => {
const requiredSymbols = ["-", ":", "T"];
if (typeof input !== "string") {
return false;
}
for (const symbol of requiredSymbols) {
if (!input.includes(symbol)) {
return false;
}
}
const year = input.split("-")[0];
if (Number.isNaN(Number(year))) {
return false;
}
if (typeof input === "string") {
const date = Date.parse(input);
if (Number.isNaN(date)) {
return false;
}
return true;
}
return false;
};
const resolveField = (fieldContent, resolvesToString = "") => {
if (!resolvesToString || typeof fieldContent !== "object") {
return null;
}
if (Array.isArray(fieldContent)) {
const fieldValue = [];
for (const entry of fieldContent) {
fieldValue.push(resolveEntry(entry, resolvesToString));
}
return fieldValue;
}
return resolveEntry(fieldContent, resolvesToString);
};
const mapFields = (entry, isHeadless, type, mainContentField, resolveList, overrides, customFields = {}) => {
const frontMatter = {};
if (isHeadless) {
frontMatter.headless = true;
}
if (type) {
frontMatter.type = type;
}
frontMatter.sys = {
id: entry.sys.id,
updatedAt: entry.sys.createdAt,
createdAt: entry.sys.updatedAt,
revision: entry.sys.revision,
space: entry.sys.space?.sys.id,
contentType: entry.sys.contentType.sys.id
};
frontMatter.date = entry.sys.createdAt;
for (const field of Object.keys(entry.fields)) {
const fieldContent = parseField(entry.fields[field]);
let fieldName = field;
const fieldOverride = shouldOverride(field, overrides);
if (fieldOverride && fieldOverride.options?.fieldName) {
fieldName = fieldOverride.options.fieldName;
}
if (fieldOverride && fieldOverride.options?.valueTransformer) {
frontMatter[fieldName] = fieldOverride.options.valueTransformer(
entry.fields[field]
);
continue;
}
const fieldResolver = shouldResolve(field, resolveList);
if (fieldResolver) {
frontMatter[fieldName] = resolveField(
fieldContent,
fieldResolver.resolveTo
);
continue;
}
if (field === mainContentField) {
continue;
} else if (field === "date") {
const d = fieldContent;
if (d.length > 10) {
frontMatter.date = new Date(d).toISOString();
} else {
frontMatter.date = d;
}
continue;
}
switch (typeof fieldContent) {
case "object":
if ("sys" in fieldContent) {
frontMatter[fieldName] = mapReferenceField(
fieldContent
);
} else if ("nodeType" in fieldContent) {
frontMatter[fieldName] = mapRichTextField(fieldContent).richText;
frontMatter[`${field}_plaintext`] = mapRichTextField(fieldContent).plainText;
} else {
frontMatter[fieldName] = mapArrayField(
Array.isArray(fieldContent) ? fieldContent : []
);
}
break;
default:
if (isDateField(fieldContent) && fieldContent.length > 10) {
frontMatter[fieldName] = new Date(
fieldContent
).toISOString();
break;
}
frontMatter[fieldName] = fieldContent;
break;
}
}
const fieldsToAppend = getCustomFields(customFields, entry);
Object.keys(fieldsToAppend).forEach((key) => {
frontMatter[key] = fieldsToAppend[key];
});
return frontMatter;
};
const getMainContent = (entry, fieldName) => {
const mainContentField = parseField(entry.fields[fieldName]);
if (typeof mainContentField === "object" && "nodeType" in mainContentField && mainContentField.nodeType === "document") {
return richTextToMarkdown(
mainContentField,
entry.sys.contentType.sys.id
);
}
if (mainContentField) {
return `
${mainContentField}`;
}
return null;
};
const parseDirectoryPath = (directory, locale) => {
const dir = removeLeadingAndTrailingSlashes(directory);
if (locale && (dir.includes("[locale]") || dir.includes("[ locale ]"))) {
const dirParts = dir.split("/");
const newDirParts = [];
for (const part of dirParts) {
if (part === "[locale]" || part === "[ locale ]") {
newDirParts.push(locale.toLowerCase());
} else {
newDirParts.push(part);
}
}
return {
path: newDirParts.join("/"),
includesLocale: true
};
}
return {
path: dir,
includesLocale: false
};
};
const determineFilePath = (contentSettings, entryId) => {
const {
fileExtension,
fileName,
isSingle,
isHeadless,
isTaxonomy,
locale
} = contentSettings;
const { path, includesLocale } = parseDirectoryPath(
contentSettings.directory,
locale.mapTo
);
let fName = entryId;
if (fileName && isSingle) {
fName = fileName;
} else if (fileName) {
fName = fileName;
}
const ext = locale.mapTo && !includesLocale ? `${locale.mapTo.toLowerCase()}.${fileExtension}` : fileExtension;
if (isHeadless && !isSingle) {
return `./${path}/${fName}/index.${ext}`;
}
if (isTaxonomy) {
return `./${path}/${fName}/_index.${ext}`;
}
if (isSingle) {
return `./${path}/${fName}.${ext}`;
}
return `./${path}/${fName}.${ext}`;
};
const createDirectoryForFile = async (contentSettings, entryId) => {
const { fileName, isSingle, isHeadless, isTaxonomy } = contentSettings;
const directory = parseDirectoryPath(
contentSettings.directory,
contentSettings.locale.mapTo
).path;
const fName = fileName || entryId;
if (isHeadless && !isSingle) {
await fs.ensureDir(`./${directory}/${fName}`);
} else if (isTaxonomy) {
await fs.ensureDir(`./${directory}/${fName}`);
} else {
await fs.ensureDir(`./${directory}`);
}
};
const cleanPreviousDynamicLocation = async (contentSettings, entryId) => {
const settings = { ...contentSettings };
settings.fileName = "";
const tmpPath = determineFilePath(settings, entryId);
const tmpPathFinal = tmpPath.replace("./", "./.contentful-hugo/");
if (await fs.pathExists(tmpPathFinal)) {
const path = (await fs.readFile(tmpPathFinal)).toString();
await fs.remove(path);
if (path.includes("/index.md")) {
await fs.remove(path.replace("/index.md", ""));
}
if (path.includes("/_index.md")) {
await fs.remove(path.replace("/_index.md", ""));
}
}
};
const logDynamicLocation = async (contentSettings, entryId, filePath) => {
const settings = { ...contentSettings };
settings.fileName = "";
const tmpPath = determineFilePath(settings, entryId);
const tmpPathFinal = tmpPath.replace("./", "./.contentful-hugo/");
await fs.ensureFile(tmpPathFinal);
await fs.writeFile(tmpPathFinal, filePath);
};
const determineDynamicLocation = async (filePath) => {
const path = filePath.replace("./", "./.contentful-hugo/");
if (await fs.pathExists(path)) {
const newPath = (await fs.readFile(path)).toString();
return newPath;
}
return filePath;
};
const setFileContent = (frontMatter, fileExtension, mainContent) => {
let fileContent = "";
switch (fileExtension) {
case "yaml":
case "yml":
fileContent += YAML.stringify(frontMatter);
break;
case "json":
fileContent += JSON.stringify(frontMatter);
break;
default:
fileContent += `---
`;
fileContent += YAML.stringify(frontMatter);
fileContent += `---
`;
if (mainContent) {
fileContent += mainContent;
}
break;
}
return fileContent;
};
const createFile = async (contentSettings, entryId, frontMatter, mainContent) => {
const { fileExtension, isHeadless, isTaxonomy, isSingle } = contentSettings;
if (isHeadless && isTaxonomy) {
throw new Error(
"A content type cannot have both isHeadless and isTaxonomy set to true"
);
}
const fileContent = setFileContent(
frontMatter,
fileExtension || null,
mainContent
);
const hasDynamicFilename = typeof contentSettings.fileName === "string" && !isSingle;
if (hasDynamicFilename) {
await cleanPreviousDynamicLocation(contentSettings, entryId);
}
await createDirectoryForFile(contentSettings, entryId);
const filePath = determineFilePath(contentSettings, entryId);
await fs.writeFile(filePath, fileContent).catch((error) => {
if (error) {
log(error);
}
});
if (hasDynamicFilename) {
await logDynamicLocation(contentSettings, entryId, filePath);
}
};
const overrideFileName = (nameStr, entry, locale) => {
const strParts = nameStr.split(".");
if (typeof entry !== "object") {
return "";
}
let result = entry;
for (const part of strParts) {
if (result[part]) {
result = result[part];
} else if (result[locale]) {
result = result[locale];
}
}
if (typeof result === "string") {
return result;
}
return null;
};
const processEntry = (item, contentSettings) => {
const {
isHeadless,
type,
mainContent,
resolveEntries,
overrides,
customFields
} = contentSettings;
const frontMatter = mapFields(
item,
isHeadless,
type,
mainContent,
resolveEntries,
overrides,
customFields
);
const content = contentSettings.mainContent ? getMainContent(item, contentSettings.mainContent) : "";
const settings = { ...contentSettings };
if (!settings.isSingle && typeof settings.fileName === "string") {
const newFileName = overrideFileName(
settings.fileName,
item,
contentSettings.locale.code
);
if (newFileName && typeof newFileName === "string") {
settings.fileName = newFileName;
} else {
settings.fileName = item.sys.id;
}
}
return createFile(settings, item.sys.id, frontMatter, content);
};
const prepDirectory = async (settings) => {
const parsedDir = parseDirectoryPath(
settings.directory,
settings.locale.mapTo
);
const newDir = parsedDir.path;
await fs.ensureDir(newDir);
if (settings.isHeadless && !settings.isSingle) {
const listPageFrontMatter = `---
# this is a work-around to prevent hugo from rendering a list page
url: /
---
`;
if (settings.locale && settings.locale.mapTo) {
await fs.writeFile(
`${newDir}/_index.${settings.locale.mapTo.toLowerCase()}.md`,
listPageFrontMatter
);
} else {
await fs.writeFile(`${newDir}/_index.md`, listPageFrontMatter);
}
}
};
const getContentType = async (limit, skip, contentSettings, contentfulSettings, previewMode = false, itemsPulled = 0, directoryPrepped = false) => {
if (!directoryPrepped) {
await prepDirectory(contentSettings);
}
const { token, previewToken, space, environment } = contentfulSettings;
if (previewMode && !previewToken) {
throw new Error(
"Environment variable CONTENTFUL_PREVIEW_TOKEN not set"
);
} else if (!previewMode && !token) {
throw new Error("Environment variable CONTENTFUL_TOKEN not set");
}
let accessToken = token;
if (previewMode) {
accessToken = previewToken || token || "";
}
const options = {
space,
host: previewMode ? "preview.contentful.com" : "cdn.contentful.com",
accessToken,
environment
};
const client = contentful.createClient(options);
if (!contentSettings.fileExtension) {
contentSettings.fileExtension = "md";
}
const query = {
content_type: contentSettings.typeId,
limit,
skip,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
order: "sys.updatedAt"
};
if (contentSettings.filters) {
const { filters } = contentSettings;
const ignoreKeys = ["content_type", "limit", "skip"];
Object.keys(filters).forEach((key) => {
if (!ignoreKeys.includes(key)) {
query[key] = filters[key];
}
});
}
if (contentSettings.locale && contentSettings.locale.code) {
query.locale = contentSettings.locale.code;
}
return client.getEntries(query).then(async (data) => {
let itemCount;
if (itemsPulled) {
itemCount = itemsPulled;
} else {
itemCount = 0;
}
const tasks = [];
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
tasks.push(processEntry(item, contentSettings));
itemCount++;
}
await Promise.all(tasks);
if (data.total > data.limit && !contentSettings.isSingle) {
const newSkip = skip + limit;
return getContentType(
limit,
newSkip,
contentSettings,
contentfulSettings,
previewMode,
itemCount,
true
);
}
return {
totalItems: itemCount,
typeId: contentSettings.typeId
};
});
};
const resultMessage = (typeId, totalItems, locale = "") => {
let grammarStuff;
if (Number(totalItems) === 1) {
grammarStuff = "item";
} else {
grammarStuff = "items";
}
if (locale) {
return ` ${typeId} (${locale}) - ${totalItems} ${grammarStuff}`;
}
return ` ${typeId} - ${totalItems} ${grammarStuff}`;
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const codeNames = [
"asset-hyperlink.html",
"embedded-asset.html",
"embedded-entry.html",
"entry-hyperlink.html",
"inline-entry.html"
];
const shortcodes = {};
for (const name of codeNames) {
const noExtension = name.replace(".html", "");
const camelCaseName = noExtension.replace(
/-([a-z])/g,
(g) => g[1].toUpperCase()
);
shortcodes[camelCaseName] = {
filename: name,
template: fs$1.readFileSync(resolve(__dirname, `./assets/shortcodes/${name}`)).toString()
};
}
const wait = (milli = 1e3) => new Promise((resolve) => {
setTimeout(() => resolve(), milli);
});
const generateConfig = async (filepath) => {
log(`creating ./contentful-hugo.config.ts`);
const configContent = `// go to https://github.com/ModiiMedia/contentful-hugo#configuration for configuration instructions
import { defineConfig } from 'contentful-hugo';
export default defineConfig({
locales: [], // uses default locale if left empty
singleTypes: [],
repeatableTypes: [],
});`;
await fs.writeFile(filepath, configContent);
log("config file created\n");
};
const checkForConfig = async () => {
log(`checking for config...`);
await wait(1e3);
const config = await loadConfig();
const filepath = "./contentful-hugo.config.ts";
if (!config) {
return generateConfig(filepath);
}
const { singleTypes, repeatableTypes } = config;
if (singleTypes || repeatableTypes) {
log(`config already exists
`);
return null;
}
return generateConfig(filepath);
};
const regex = /<<([^%>]+)?>>/g;
const getVariablesFromTemplate = (template) => {
const params = template.match(regex);
return params;
};
const templateVariableValues = {
"<<BACKGROUND_COLOR>>": "rgb(255, 231, 231)",
"<<TEXT_COLOR>>": "red",
"<<BORDER_COLOR>>": "red"
};
const replaceVariablesWithValues = (template) => {
const values = templateVariableValues;
const params = getVariablesFromTemplate(template);
let newTemplate = template;
if (params && params.length) {
for (const param of params) {
newTemplate = newTemplate.replace(param, values[param]);
}
}
return newTemplate;
};
const addShortcodes = async (override = false) => {
log("adding shortcodes for rich text...");
await wait(1e3);
const directory = "./layouts/shortcodes/contentful-hugo";
await fs.ensureDir(directory);
const handleShortCode = async (key) => {
const { filename, template } = shortcodes[key];
const finalTemplate = replaceVariablesWithValues(template);
const filepath = `${directory}/${filename}`;
if (await fs.pathExists(filepath) && !override) {
log(`${filepath} already exists`);
} else {
await fs.writeFile(filepath, finalTemplate);
log(`created ${filepath}`);
}
};
const tasks = [];
Object.keys(shortcodes).forEach(async (key) => {
tasks.push(handleShortCode(key));
});
await Promise.all(tasks).then(() => {
log("\n");
});
return null;
};
const initializeDirectory = async (override = false) => {
await checkForConfig();
await addShortcodes(override);
};
const cleanInputAndOutput = (inputDir, outputDir) => {
const input = removeLeadingAndTrailingSlashes(inputDir.replace("./", ""));
const output = removeLeadingAndTrailingSlashes(outputDir.replace("./", ""));
return {
input,
output
};
};
const copyFileToOutputDirectory = async (filePath, inputDir, outputDir) => {
const { input, output } = cleanInputAndOutput(inputDir, outputDir);
const newFilePath = filePath.replace(`${input}/`, `${output}/`);
await fs.ensureFile(newFilePath);
return copyFile(filePath, newFilePath);
};
const deleteFileFromOutputDirectory = (filePath, inputDir, outputDir) => {
const { input, output } = cleanInputAndOutput(inputDir, outputDir);
const newFilePath = filePath.replace(`${input}/`, `${output}/`);
return unlink(newFilePath);
};
const copyInputDirectoryToOutputDirectory = async (inputDir, outputDir) => {
await fs.ensureDir(inputDir);
const srcPath = path.resolve(inputDir);
const outPath = path.resolve(outputDir);
return fs.copy(srcPath, outPath);
};
const copyStaticContent = async (config) => {
if (!config.staticContent || !config.staticContent.length) {
return;
}
const tasks = [];
for (const item of config.staticContent) {
const { inputDir, outputDir } = item;
if (!inputDir) {
throw new Error(
`staticContent item config is missing required field "inputDir"`
);
}
if (!outputDir) {
throw new Error(
`staticContent item config is missing required field "outputDir"`
);
}
tasks.push(copyInputDirectoryToOutputDirectory(inputDir, outputDir));
}
await Promise.all(tasks);
};
const fetchType = (limit, skip, settings, contentfulSettings, preview = false) => getContentType(limit, skip, settings, contentfulSettings, preview).then((result) => {
log(
resultMessage(
result.typeId,
result.totalItems,
settings.locale.code
)
);
}).catch((error) => {
log(error);
if (typeof error === "string") {
throw new Error(error);
}
const { sys } = error;
if (sys && sys.id && sys.id === "InvalidQuery") {
log(
` --------------------------
${settings.typeId} - ERROR ${error.message} ${JSON.stringify(
error.details
)})
--------------------------`,
LogTypes.warn
);
} else {
throw new Error(`${JSON.stringify(error)}`);
}
});
const configCheck = (config) => {
const { space, token, environment } = config.contentful;
const missingParams = [];
const missingEnvVars = [];
if (!space) {
missingParams.push("contentful.space");
missingEnvVars.push("CONTENTFUL_SPACE");
}
if (!token) {
missingParams.push("contentful.token");
missingEnvVars.push("CONTENTFUL_TOKEN");
}
if (!environment) {
missingParams.push("contentful.environment");
missingEnvVars.push("CONTENTFUL_PREVIEW_TOKEN");
}
if (missingParams.length > 0) {
const errorMessage = `Missing [${missingParams.join(", ")}]. Update your config or set the following environment variables: [${missingEnvVars.join(", ")}]`;
throw new Error(errorMessage);
}
return null;
};
const fetchDataFromContentful = async (config, previewMode = false, waitTime = 0) => {
const isPreview = previewMode;
const deliveryMode = previewMode ? "Preview Data" : "Published Data";
configCheck(config);
if (waitTime && typeof waitTime === "number") {
log(`waiting ${waitTime}ms...`, LogTypes.warn);
await new Promise((resolve) => {
setTimeout(() => {
resolve(null);
}, waitTime);
});
}
log(
`
---------------------------------------------
Pulling ${deliveryMode} from Contentful...
---------------------------------------------
`
);
const jobs = [];
const addJob = (item, isSingle) => {
let settings;
if (isSingle) {
settings = {
typeId: item.id,
directory: item.directory,
locale: {
code: "",
mapTo: ""
},
fileExtension: item.fileExtension,
fileName: item.fileName,
mainContent: item.mainContent,
isSingle: true,
type: item.type,
resolveEntries: getResolveEntryConfigs(item.resolveEntries),
overrides: getOverrideConfigs(item.overrides),
filters: item.filters,
customFields: item.customFields || {}
};
} else {
settings = {
typeId: item.id,
locale: {
code: "",
mapTo: ""
},
directory: item.directory,
isHeadless: item.isHeadless,
fileExtension: item.fileExtension,
mainContent: item.mainContent,
type: item.type,
isTaxonomy: item.isTaxonomy,
resolveEntries: getResolveEntryConfigs(item.resolveEntries),
overrides: getOverrideConfigs(item.overrides),
filters: item.filters,
fileName: item.fileName,
customFields: item.customFields || {}
};
}
if (isValidFileExtension(settings.fileExtension)) {
const pageSize = config.contentful.pageSize ? config.contentful.pageSize : 1e3;
if (config.locales.length && !item.ignoreLocales) {
for (const locale of config.locales) {
const newSettings = { ...settings };
if (typeof locale === "string") {
newSettings.locale = {
code: locale,
mapTo: locale
};
} else {
newSettings.locale = locale;
}
const job = {
limit: isSingle ? 1 : pageSize,
skip: 0,
contentSettings: newSettings,
isPreview
};
jobs.push(job);
}
} else {
const job = {
limit: isSingle ? 1 : pageSize,
skip: 0,
contentSettings: settings,
isPreview
};
jobs.push(job);
}
} else {
log(
` ERROR: extension "${settings.fileExtension}" not supported`,
LogTypes.warn
);
}
};
const repeatables = config.repeatableTypes;
if (repeatables) {
for (let i = 0; i < repeatables.length; i++) {
addJob(repeatables[i], false);
}
}
const singles = config.singleTypes;
if (singles) {
for (let i = 0; i < singles.length; i++) {
addJob(singles[i], true);
}
}
return new Promise((resolve) => {
const t = new Limiter({ concurrency: 2 });
for (const job of jobs) {
t.push((cb) => {
fetchType(
job.limit,
job.skip,
job.contentSettings,
config.contentful,
job.isPreview
).then(() => {
cb();
});
});
}
t.onDone(() => {
log(`
---------------------------------------------
`);
resolve();
});
});
};
export { determineDynamicLocation as a, getOverrideConfigs as b, getContentType as c, determineFilePath as d, cleanInputAndOutput as e, replaceBackslashesWithForwardSlashes as f, getResolveEntryConfigs as g, copyFileToOutputDirectory as h, deleteFileFromOutputDirectory as i, initLogger as j, initializeDirectory as k, log as l, loadConfig as m, copyStaticContent as n, fetchDataFromContentful as o, defineConfig as p, removeLeadingAndTrailingSlashes as r };