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.
606 lines (590 loc) • 18.1 kB
JavaScript
#!/usr/bin/env node
'use strict';
const dotenv = require('dotenv');
const citty = require('citty');
const index = require('./shared/contentful-hugo.BWF3yQhL.cjs');
const promises = require('node:fs/promises');
const Fastify = require('fastify');
const path = require('path');
const fs = require('fs-extra');
const contentful = require('contentful');
const chokidar = require('chokidar');
require('async-limiter');
require('node:fs');
require('js-yaml');
require('c12');
require('@contentful/rich-text-plain-text-renderer');
require('@contentful/rich-text-types');
require('@contentful/rich-text-html-renderer');
require('json-to-pretty-yaml');
require('fs');
require('url');
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
function _interopNamespaceCompat(e) {
if (e && typeof e === 'object' && 'default' in e) return e;
const n = Object.create(null);
if (e) {
for (const k in e) {
n[k] = e[k];
}
}
n.default = e;
return n;
}
const dotenv__default = /*#__PURE__*/_interopDefaultCompat(dotenv);
const Fastify__default = /*#__PURE__*/_interopDefaultCompat(Fastify);
const fs__default = /*#__PURE__*/_interopDefaultCompat(fs);
const contentful__namespace = /*#__PURE__*/_interopNamespaceCompat(contentful);
const chokidar__default = /*#__PURE__*/_interopDefaultCompat(chokidar);
const LOG_PREFIX = "[contentful hugo]";
const cleanDirectories = async (config) => {
const dirs = [".contentful-hugo"];
const getRootDir = (directory) => {
const dir = index.removeLeadingAndTrailingSlashes(
directory.replace("./", "")
);
const dirParts = dir.split("/");
return dirParts[0];
};
for (const item of config.singleTypes) {
const newDir = getRootDir(item.directory);
if (!dirs.includes(newDir)) {
dirs.push(newDir);
}
}
for (const item of config.repeatableTypes) {
const newDir = getRootDir(item.directory);
if (!dirs.includes(newDir)) {
dirs.push(newDir);
}
}
const tasks = [];
for (const dir of dirs) {
tasks.push(
promises.rm(dir, { recursive: true, force: true }).then(() => {
index.log(`${LOG_PREFIX} cleaned ./${dir}`);
})
);
}
await Promise.all(tasks);
};
const getSingleTypeConfigs = (config, contentType) => {
const configs = [];
for (const item of config.singleTypes) {
if (item.id === contentType) {
const con = {
typeId: item.id,
locale: {
code: "",
mapTo: ""
},
directory: item.directory,
fileExtension: item.fileExtension || "md",
fileName: item.fileName,
isHeadless: false,
isSingle: true,
isTaxonomy: false,
mainContent: item.mainContent,
overrides: index.getOverrideConfigs(item.overrides),
resolveEntries: index.getResolveEntryConfigs(item.resolveEntries),
filters: item.filters,
customFields: item.customFields || {}
};
if (config.locales && config.locales.length && !item.ignoreLocales) {
for (const locale of config.locales) {
const configWithLocale = { ...con };
if (typeof locale === "string") {
configWithLocale.locale = {
code: locale,
mapTo: locale
};
} else {
configWithLocale.locale = locale;
}
configs.push(configWithLocale);
}
} else {
configs.push(con);
}
}
}
return configs;
};
const getRepeatableTypeConfigs = (config, contentType) => {
const configs = [];
for (const item of config.repeatableTypes) {
if (item.id === contentType) {
const con = {
typeId: item.id,
directory: item.directory,
fileExtension: item.fileExtension || "md",
isHeadless: item.isHeadless,
isTaxonomy: item.isTaxonomy,
isSingle: false,
mainContent: item.mainContent || "",
overrides: index.getOverrideConfigs(item.overrides),
filters: item.filters,
fileName: item.fileName,
resolveEntries: index.getResolveEntryConfigs(item.resolveEntries),
locale: {
code: "",
mapTo: ""
},
customFields: item.customFields || {}
};
if (config.locales && config.locales.length && !item.ignoreLocales) {
for (const locale of config.locales) {
const configWithLocale = { ...con };
if (typeof locale === "string") {
configWithLocale.locale = {
code: locale,
mapTo: locale
};
} else {
configWithLocale.locale = locale;
}
configs.push(configWithLocale);
}
} else {
configs.push(con);
}
}
}
return configs;
};
const determineFileLocations = async (config, entryId, contentType, isDeleting = false) => {
const singleConfigs = getSingleTypeConfigs(
config,
contentType
);
const locations = [];
for (const item of singleConfigs) {
const location = index.determineFilePath(item, entryId);
if (isDeleting) {
const fileExists = await fs__default.pathExists(location);
if (fileExists) {
const data = await fs__default.readFile(location);
if (data.includes(`id: "${entryId}"`)) {
locations.push(location);
}
}
} else {
locations.push(location);
}
}
const repeatableConfigs = getRepeatableTypeConfigs(
config,
contentType
);
for (const item of repeatableConfigs) {
const itemCopy = { ...item };
itemCopy.fileName = entryId;
let path = index.determineFilePath(itemCopy, entryId);
if (item.fileName) {
path = await index.determineDynamicLocation(path);
}
if (!isDeleting && path.includes(`/${item.fileName}`)) {
path = path.replace(`/${item.fileName}`, `/[${item.fileName}]`);
}
locations.push(path);
}
return locations;
};
const fetchEntryFromContentful = async (entryId, contentType, config, previewMode = true) => {
const configs = getRepeatableTypeConfigs(config, contentType).concat(
getSingleTypeConfigs(config, contentType)
);
const tasks = [];
for (const cf of configs) {
const contentSettings = {
typeId: cf.typeId,
directory: cf.directory,
fileExtension: cf.fileExtension,
fileName: cf.fileName,
titleField: cf.titleField,
dateField: cf.dateField,
isHeadless: cf.isHeadless,
isTaxonomy: cf.isTaxonomy,
isSingle: cf.isSingle,
type: cf.type,
resolveEntries: cf.resolveEntries,
overrides: cf.overrides,
mainContent: cf.mainContent,
filters: cf.filters || {},
locale: cf.locale,
customFields: cf.customFields || {}
};
contentSettings.filters["sys.id"] = entryId;
const contentfulSettings = config.contentful;
tasks.push(
index.getContentType(
100,
0,
contentSettings,
contentfulSettings,
previewMode,
0
)
);
}
await Promise.all(tasks);
};
const deleteFile = async (filePath, quietMode = false) => {
if (!filePath) {
return null;
}
const path$1 = path.resolve(filePath);
if (await fs__default.pathExists(path$1)) {
await fs__default.unlink(path$1);
if (!quietMode) {
index.log(`[contentful hugo] deleted ${path$1}`);
}
}
return null;
};
const updateEntry = (config, sys, previewMode) => fetchEntryFromContentful(sys.id, sys.contentType.sys.id, config, previewMode).then(
async () => {
const fileLocations = await determineFileLocations(
config,
sys.id,
sys.contentType.sys.id,
false
);
for (const location of fileLocations) {
index.log(`[contentful hugo] created ${path.resolve(location)}`);
}
const message = `Created ${fileLocations.length} file${fileLocations.length === 1 ? "" : "s"}`;
const payload = {
message,
date: /* @__PURE__ */ new Date(),
entryId: sys.id,
contentType: sys.contentType.sys.id,
files: fileLocations
};
return payload;
}
);
const removeEntry = async (config, sys) => {
const filePaths = await determineFileLocations(
config,
sys.id,
sys.contentType.sys.id,
true
);
const tasks = [];
for (const path of filePaths) {
tasks.push(deleteFile(path));
}
return Promise.all(tasks).then(() => {
const message = `Deleted ${filePaths.length} file${filePaths.length === 1 ? "" : "s"}`;
return {
date: /* @__PURE__ */ new Date(),
message,
entryId: sys.id,
contentType: sys.contentType.sys.id,
files: filePaths
};
});
};
const createContentfulClient = (settings, previewMode = false) => {
const { token, previewToken, space, environment } = settings;
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
};
return contentful__namespace.createClient(options);
};
const fetchEntriesLinkedToAsset = async (assetId, config, previewMode = false) => {
const client = createContentfulClient(config.contentful, previewMode);
const entries = [];
const getEntries = async (skip = 0) => {
const limit = 100;
const e = await client.getEntries({
links_to_asset: assetId,
limit
});
for (const item of e.items) {
entries.push({
id: item.sys.id,
contentType: item.sys.contentType.sys.id
});
}
if (entries.length !== e.total) {
return getEntries(skip + limit);
}
return true;
};
await getEntries(0);
const tasks = [];
for (const entry of entries) {
tasks.push(
fetchEntryFromContentful(
entry.id,
entry.contentType,
config,
previewMode
)
);
}
await Promise.all(tasks);
index.log(`[contentful hugo] fetched entries linked to asset ${assetId}`);
};
const createWatcher = (config) => {
if (!config.singleTypes || !config.staticContent.length) {
return;
}
const directoryMap = {};
const watchPaths = [];
for (const item of config.staticContent) {
const { input, output } = index.cleanInputAndOutput(
item.inputDir,
item.outputDir
);
watchPaths.push(input);
directoryMap[input] = output;
}
const getRootDir = (path) => {
const filePath = index.removeLeadingAndTrailingSlashes(path);
const pathParts = filePath.split("/");
return pathParts[0];
};
const handleCopy = async (path) => {
const rootDir = getRootDir(path);
const outDir = directoryMap[rootDir];
if (!outDir) {
return;
}
await index.copyFileToOutputDirectory(path, rootDir, outDir);
};
const handleDelete = async (path) => {
const rootDir = getRootDir(path);
const outDir = directoryMap[rootDir];
if (!outDir) {
return;
}
await index.deleteFileFromOutputDirectory(path, rootDir, outDir);
};
const watcher = chokidar__default.watch(watchPaths, {
persistent: true
});
watcher.on("add", (path) => {
index.log(`${LOG_PREFIX} ${path} added`);
return handleCopy(index.replaceBackslashesWithForwardSlashes(path));
});
watcher.on("change", (path) => {
index.log(`${LOG_PREFIX} ${path} changed`);
return handleCopy(index.replaceBackslashesWithForwardSlashes(path));
});
watcher.on("unlink", (path) => {
index.log(`${LOG_PREFIX} ${path} deleted`);
return handleDelete(index.replaceBackslashesWithForwardSlashes(path));
});
};
const app = Fastify__default({});
const contentfulContentTypes = [
"application/vnd.contentful.management.v1+json",
"application/vnd.contentful.management.v1+json; charset=utf-8",
"application/json",
"application/json; charset=utf-8",
"application/x-www-form-urlencoded",
"application/x-www-form-urlencoded; charset=utf-8"
];
app.addContentTypeParser(
contentfulContentTypes,
{ parseAs: "string" },
app.getDefaultJsonParser("ignore", "ignore")
);
const isAssetTrigger = (triggerType, previewMode) => {
if (previewMode) {
return triggerType === "ContentManagement.Asset.archive" || triggerType === "ContentManagement.Asset.auto_save" || triggerType === "ContentManagement.Asset.create" || triggerType === "ContentManagement.Asset.delete" || triggerType === "ContentManagement.Asset.publish" || triggerType === "ContentManagement.Asset.save" || triggerType === "ContentManagement.Asset.unarchive" || triggerType === "ContentManagement.Asset.unpublish";
}
return triggerType === "ContentManagement.Asset.unpublish" || triggerType === "ContentManagement.Asset.unarchive" || triggerType === "ContentManagement.Asset.delete" || triggerType === "ContentManagement.Asset.publish";
};
const shouldCreate = (triggerType, previewMode) => {
const conditions = [".publish", ".unarchive"];
for (const condition of conditions) {
if (triggerType.includes(condition)) {
return true;
}
}
if (previewMode) {
const previewCondition = [".save", ".create", ".auto_save"];
for (const condition of previewCondition) {
if (triggerType.includes(condition)) {
return true;
}
}
}
return false;
};
const shouldDelete = (triggerType, previewMode) => {
const conditions = [".delete", ".archive"];
for (const condition of conditions) {
if (triggerType.includes(condition)) {
return true;
}
}
if (!previewMode) {
const previewConditions = [".unpublish"];
for (const condition of previewConditions) {
if (triggerType.includes(condition)) {
return true;
}
}
}
return false;
};
const startServer = async (config, port = 1414, previewMode = false) => {
if (!config) {
throw new Error("Missing contentful hugo config");
}
app.get("/status", (_req, res) => res.status(200).send("ok"));
app.post("/", async (req, res) => {
if (!req.body.sys) {
return res.status(401).send();
}
const { sys } = req.body;
if (!sys || !sys.id || sys.type !== "Asset" && sys.type !== "Entry" && sys.type !== "ContentType" && sys.type !== "DeletedEntry") {
return res.status(401).send("Invalid format");
}
const triggerType = req.headers["x-contentful-topic"];
if (typeof triggerType !== "string") {
return res.status(401).send("Invalid format");
}
if (isAssetTrigger(triggerType, previewMode)) {
return fetchEntriesLinkedToAsset(sys.id, config, previewMode).then(
() => {
const payload = {
assetId: sys.id,
date: /* @__PURE__ */ new Date(),
message: `Updated entries linked to asset ${sys.id}`,
files: []
};
return res.status(200).send(payload);
}
);
}
if (shouldCreate(triggerType, previewMode)) {
switch (sys.type) {
case "Entry":
return updateEntry(
config,
sys,
previewMode
).then((payload) => res.status(200).send(payload));
}
}
if (shouldDelete(triggerType, previewMode)) {
switch (sys.type) {
case "Entry":
case "DeletedEntry":
return removeEntry(config, sys).then(
(payload) => res.status(200).send(payload)
);
}
}
return res.status(200).send({
id: sys.id,
type: sys.type,
message: "Did nothing"
});
});
await app.listen({ port });
console.log(`[contentful hugo] server started at http://localhost:${port}`);
createWatcher(config);
};
dotenv__default.config({ quiet: true });
const main = citty.defineCommand({
meta: {
name: "Contentful Hugo"
},
args: {
preview: {
type: "boolean",
default: false,
alias: "P",
description: "Pulls published and unplublished entries"
},
init: {
type: "boolean",
default: false,
description: "Initialize directory for Contentful-Hugo"
},
wait: {
type: "string",
default: "0",
alias: "W",
description: "Wait X number of ms before fetching data"
},
config: {
type: "string",
default: "",
alias: "C",
description: "Specify path to a config file"
},
server: {
type: "boolean",
default: false,
alias: "S",
description: "Run a server that can receive webhooks from Contentful to trigger Contentful Hugo"
},
port: {
type: "string",
default: "1414",
description: "Specify server port"
},
clean: {
type: "boolean",
default: false,
description: "Delete all output directories"
},
quiet: {
type: "boolean",
default: false,
alias: "Q",
description: "'Run without emitting any logs'"
}
},
async run({ args }) {
const log = index.initLogger(args.quiet);
if (args.init) {
return index.initializeDirectory();
}
const config = await index.loadConfig(".", args.config);
if (config === false) {
throw new Error(
`There is an error in your config file, or it doesn't exits.
Check your config for errors or run "contentful-hugo --init" to create a config file.
`
);
}
if (args.clean) {
return cleanDirectories(config);
}
if (config.staticContent && config.staticContent.length) {
log(`${LOG_PREFIX} Copying static content...`);
await index.copyStaticContent(config);
}
const waitVal = Number.isNaN(args.wait) ? 0 : Number(args.wait);
const portVal = Number.isNaN(args.port) ? 1414 : Number(args.port);
await index.fetchDataFromContentful(config, args.preview || false, waitVal);
if (args.server) {
return startServer(config, portVal, args.preview || false);
}
return null;
}
});
citty.runMain(main);