UNPKG

@paroicms/connector

Version:

An API to help build management tools for ParoiCMS instances

558 lines 21.5 kB
import { PART_DEFAULT_LIST_NAME } from "@paroicms/internal-anywhere-lib"; import { ensureLanguagesFormat, } from "@paroicms/public-server-lib"; import { type } from "arktype"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { siteSchemaFormatVersion } from "../constants.js"; import { typeValidator } from "../context.js"; import { defaultImageQualityPolicy, defaultMediaPolicy } from "./default-media-policy.js"; import { completeSchemaLocales, createEmptySchemaLocales, importPartOfL10n, makeSiteLanguageLabels, } from "./read-locales.js"; import { prepareLib } from "./schema-lib.js"; import { checkSiteSchemaVersion } from "./site-schema-helpers.js"; import { parseSorting } from "./site-schema-public-helpers.js"; const StringAT = type("string"); const authorizedClassicRoute = new Set([":relativeId-:slug", ":yyyy/:mm/:dd/:relativeId-:slug"]); const invalidFieldNames = ["featuredImage", "adminUiFavicon"]; export async function loadSiteSchema({ logger, regSite, commonSchemaLib, pluginStaticConfigurations, trusted, }) { const jsonData = await readJtSiteSchema(join(regSite.siteDir, "site-schema.json"), siteSchemaFormatVersion); if (jsonData.languages.length === 0) throw new Error("missing languages in site-schema"); const languages = ensureLanguagesFormat(jsonData.languages); const lib = await prepareLib({ logger, regSite, pluginStaticConfigurations, commonSchemaLib, languages, pluginNamesOrRefs: jsonData.plugins, }); const l10n = createEmptySchemaLocales(languages, lib.common.languageLabels); const imageQualityPolicy = jsonData.imageQualityPolicy ?? lib.schema.imageQualityPolicy ?? defaultImageQualityPolicy; if (!imageQualityPolicy) throw new Error("missing 'imageQualityPolicy' in site-schema"); const rawRoutingMode = jsonData.languageRoutingMode ?? "auto"; const languageRoutingMode = rawRoutingMode === "auto" ? languages.length === 1 ? "prefixSecondary" : "prefixAll" : rawRoutingMode; const newSchema = { languages, isMultilingual: languages.length > 1, languageRoutingMode, languageLabels: makeSiteLanguageLabels(languages, lib.common.languageLabels), defaultLanguage: languages[0], nodeTypes: { _site: undefined, // will be created later home: undefined, // will be created later }, mediaPolicies: { default: undefined, // will be created later }, imageQualityPolicy, l10n, }; const ctx = { lib, newSchema, mediaPoliciesJsonData: new Map((jsonData.mediaPolicies ?? []).map((p) => [p.policyName, p])), importTypeNames: [], trusted, }; appendNodeTypesToSiteSchema(ctx, jsonData); importNodeTypesToSiteSchema(ctx); await completeSchemaLocales(regSite.siteDir, l10n); // Update languageLabels after completeSchemaLocales, which might have added English const finalLanguages = Object.keys(l10n); newSchema.languageLabels = makeSiteLanguageLabels(finalLanguages, lib.common.languageLabels); if (!newSchema.mediaPolicies.default) { const mediaPolicyJsonData = ctx.mediaPoliciesJsonData.get("default"); if (mediaPolicyJsonData) { if (!mediaPolicyJsonData.image) { throw new Error(`missing 'image' in media policy 'default'`); } newSchema.mediaPolicies.default = mediaPolicyJsonData; } else { newSchema.mediaPolicies.default = defaultMediaPolicy; } } checkIntegrityOfSiteSchema(newSchema); return { siteSchema: newSchema, plugins: Array.from(lib.plugins?.values() ?? []), siteConfiguration: jsonData.configuration ?? {}, }; } function appendNodeTypesToSiteSchema(ctx, jsonData) { const { newSchema } = ctx; for (const item of jsonData.nodeTypes ?? []) { const typeName = item.kind === "site" ? "_site" : item.typeName; if (newSchema.nodeTypes[typeName]) throw new Error(`Duplicated node type '${typeName}'`); switch (item.kind) { case "site": newSchema.nodeTypes._site = toSiteDataType(ctx, item); break; case "document": newSchema.nodeTypes[item.typeName] = toDocumentType(ctx, { jsonData: item, languages: newSchema.languages, }); break; case "part": newSchema.nodeTypes[item.typeName] = toPartType(ctx, item, newSchema.l10n); break; default: throw new Error(`unknown node type kind '${item.kind}'`); } } if (!newSchema.nodeTypes._site) { newSchema.nodeTypes._site = toSiteDataType(ctx, undefined); } if (!newSchema.nodeTypes.home) { importDocumentType(ctx, "home", { documentKind: "routing" }); } const { _site, home, ...rest } = newSchema.nodeTypes; newSchema.nodeTypes = { _site, home, ...rest }; } function importNodeTypesToSiteSchema(ctx) { const { newSchema, importTypeNames } = ctx; while (true) { const item = importTypeNames.shift(); if (!item) break; if (newSchema.nodeTypes[item.typeName]) continue; switch (item.kind) { case "document": importDocumentType(ctx, item.typeName, { skipDuplicate: true, documentKind: item.documentKind, }); break; case "part": importPartType(ctx, item.typeName, { skipDuplicate: true }); break; default: throw new Error(`unknown node type kind '${item.kind}'`); } } } function toSiteDataType(ctx, jsonData) { const { newSchema, lib } = ctx; importPartOfL10n({ from: lib.common.l10n, fromPath: ["siteFields"], to: newSchema.l10n, toPath: ["nodeTypes", "_site", "fields"], ifDuplicate: "overwrite", }); const jsonSiteFields = jsonData?.fields ?? lib.schema.nodeTypes?._site?.fields ?? []; const siteFields = jsonSiteFields.map((item) => typeof item === "string" ? importFieldType(ctx, { fieldName: item, fillL10n: newSchema.l10n, fillL10nPath: ["nodeTypes", "_site", "fields"], }) : toFieldType(ctx, item)); checkSiteFieldNames(siteFields, lib); const siteType = { kind: "site", typeName: "_site", fields: [...lib.common.siteFields.map((f) => toFieldType(ctx, f)), ...siteFields], }; if (!ctx.trusted) { const contactEmail = siteType.fields.find((f) => f.name === "contactEmail"); if (contactEmail) { contactEmail.readOnly = true; } } return siteType; } function toDocumentType(ctx, { jsonData, languages, }) { const { newSchema } = ctx; const jsonFields = jsonData.fields; const fields = jsonFields === undefined ? undefined : jsonFields.map((item) => typeof item === "string" ? importFieldType(ctx, { fieldName: item, fillL10n: newSchema.l10n, fillL10nPath: ["nodeTypes", jsonData.typeName, "fields"], }) : toFieldType(ctx, item)); if (fields) checkDuplicatedFieldNames(fields); for (const list of jsonData.lists ?? []) { for (const typeName of list.parts) { ctx.importTypeNames.push({ typeName, kind: "part" }); } } if (jsonData.documentKind === "routing") { return toRoutingDocumentType(jsonData, languages, fields); } return toRegularDocumentSchema(jsonData, fields); } function importDocumentTypeDependencies(ctx, jsonData) { for (const listType of jsonData.lists ?? []) { for (const typeName of listType.parts) { ctx.importTypeNames.push({ typeName, kind: "part" }); } } for (const childTypeName of jsonData.routingChildren ?? []) { ctx.importTypeNames.push({ typeName: childTypeName, kind: "document", documentKind: "routing", }); } for (const childTypeName of jsonData.regularChildren ?? []) { ctx.importTypeNames.push({ typeName: childTypeName, kind: "document", documentKind: "regular", }); } if (jsonData.mediaPolicy) { importMediaPolicy(ctx, jsonData.mediaPolicy, { skipDuplicate: true }); } } function toRoutingDocumentType(jsonData, languages, fields) { const kebabName = camelToKebabCase(jsonData.typeName); let route; if (jsonData.typeName !== "home") { if (!jsonData.route) { throw new Error(`missing route in '${jsonData.typeName}'`); } route = toUrlPaths(jsonData.route, languages); } const children = jsonData.regularChildren && jsonData.regularChildren.length > 0 ? jsonData.regularChildren : undefined; if (children && !jsonData.regularChildrenSorting) { throw new Error(`Missing 'regularChildrenSorting' in "${jsonData.typeName}`); } if (jsonData.hasFrontendApp && children) { throw new Error(`Document "${jsonData.typeName}" can't have child documents if it is a frontend application`); } if (jsonData.hasFrontendApp && jsonData.redirectTo) { throw new Error(`Document "${jsonData.typeName}" can't be a redirection if it is a frontend application`); } return { kind: "document", documentKind: "routing", typeName: jsonData.typeName, kebabName, route, redirectTo: jsonData.typeName === "home" ? undefined : jsonData.redirectTo, fields, mediaPolicy: jsonData.mediaPolicy, lists: jsonData.lists ? jsonData.lists.map(toListType) : undefined, routingChildren: jsonData.routingChildren, regularChildren: children, regularChildrenSorting: jsonData.regularChildrenSorting ? parseSorting(jsonData.regularChildrenSorting) : undefined, childLimit: jsonData.childLimit, hasFrontendApp: jsonData.hasFrontendApp, useUrlQuery: jsonData.useUrlQuery, templateNames: [kebabName], ogType: jsonData.ogType, withFeaturedImage: jsonData.withFeaturedImage, adminUi: jsonData.adminUi, }; } function toListType(item) { return { listName: item.listName, sorting: parseSorting(item.sorting), parts: item.parts, limit: item.limit, }; } function toUrlPaths(route, languages, defaultUrlPaths) { const urlPaths = {}; if (typeof route === "string") { for (const language of languages) { urlPaths[language] = route; } } else { const defaultLanguage = languages[0]; if (!defaultLanguage) throw new Error("missing languages in site-schema"); for (const language of languages) { const val = route[language] ?? defaultUrlPaths?.[language] ?? route[defaultLanguage]; if (!val) throw new Error(`missing route for language "${language}" in site-schema`); if (val.includes(":")) throw new Error("route should not contain ':'"); urlPaths[language] = val; } } return urlPaths; } function toRegularDocumentSchema(jsonData, fields) { if (jsonData.route && (typeof jsonData.route !== "string" || !authorizedClassicRoute.has(jsonData.route))) { throw new Error(`invalid route '${jsonData.route}'`); } const kebabName = camelToKebabCase(jsonData.typeName); if (!jsonData.route) { throw new Error(`missing route in '${jsonData.typeName}'`); } const children = jsonData.regularChildren && jsonData.regularChildren.length > 0 ? jsonData.regularChildren : undefined; if (children && !jsonData.regularChildrenSorting) { throw new Error(`Missing 'regularChildrenSorting' in "${jsonData.typeName}`); } if (jsonData.routingChildren) { throw new Error(`Regular document "${jsonData.typeName}" can't have child routing documents`); } if (jsonData.hasFrontendApp && children) { throw new Error(`Document "${jsonData.typeName}" can't have child documents if it is a frontend application`); } return { kind: "document", documentKind: "regular", typeName: jsonData.typeName, kebabName, route: jsonData.route, fields, mediaPolicy: jsonData.mediaPolicy, lists: jsonData.lists ? jsonData.lists.map(toListType) : undefined, regularChildren: children, regularChildrenSorting: jsonData.regularChildrenSorting ? parseSorting(jsonData.regularChildrenSorting) : undefined, childLimit: jsonData.childLimit, hasFrontendApp: jsonData.hasFrontendApp, useUrlQuery: jsonData.useUrlQuery, autoPublish: jsonData.autoPublish, relativeIdGenerator: formatRelativeIdGeneratorSchema(jsonData.relativeIdGenerator, { varName: "relativeIdGenerator", }), templateNames: [kebabName], ogType: jsonData.ogType, withFeaturedImage: jsonData.withFeaturedImage, adminUi: jsonData.adminUi, }; } function formatRelativeIdGeneratorSchema(val, formatOptions) { if (!val) return; if (val.length === 0) { throw new Error(`invalid empty value for '${formatOptions.varName}'`); } val[0] = StringAT.assert(val[0]); return val; } function checkIntegrityOfSiteSchema(newSchema) { if (newSchema.nodeTypes.home && (newSchema.nodeTypes.home.kind !== "document" || newSchema.nodeTypes.home.documentKind !== "routing")) { throw new Error("Invalid home document type"); } for (const nodeType of Object.values(newSchema.nodeTypes)) { if (nodeType.kind !== "document") continue; let childRoute; for (const childTypeName of nodeType.regularChildren ?? []) { const child = newSchema.nodeTypes[childTypeName]; if (!child || child.kind !== "document") { throw new Error(`Unknown child document type '${childTypeName}'`); } if (child.documentKind === "regular") { if (childRoute === undefined) { childRoute = child.route; } else if (child.route !== childRoute) { throw new Error(`Inconsistent routes '${childRoute}' and '${child.route}' for children of document type '${nodeType.typeName}'`); } } } } } function toPartType(ctx, jsonData, l10n) { const fields = jsonData.fields === undefined ? undefined : jsonData.fields.map((item) => typeof item === "string" ? importFieldType(ctx, { fieldName: item, fillL10n: l10n, fillL10nPath: ["nodeTypes", jsonData.typeName, "fields"], }) : toFieldType(ctx, item)); if (fields) checkDuplicatedFieldNames(fields); if (jsonData.list) { for (const listType of jsonData.list.parts) { ctx.importTypeNames.push({ typeName: listType, kind: "part" }); } } const kebabName = camelToKebabCase(jsonData.typeName); return { kind: "part", typeName: jsonData.typeName, kebabName, fields, mediaPolicy: jsonData.mediaPolicy, list: jsonData.list ? { listName: PART_DEFAULT_LIST_NAME, parts: jsonData.list.parts, limit: jsonData.list.limit, sorting: parseSorting(jsonData.list.sorting), } : undefined, }; } function importDocumentType(ctx, typeName, options) { const { newSchema, lib } = ctx; const { documentKind, skipDuplicate } = options; const existingType = newSchema.nodeTypes[typeName]; if (existingType) { if (existingType.kind !== "document") { throw new Error(`Node type '${typeName}' can't be both a document and a ${existingType.kind}`); } if (existingType.documentKind !== documentKind) { throw new Error(`Node type '${typeName}' can't be both ${documentKind} and ${existingType.documentKind}`); } if (skipDuplicate) return; throw new Error(`Duplicated document type '${typeName}'`); } const jsonData = lib.schema.nodeTypes[typeName]; if (!jsonData || jsonData.kind !== "document") { throw new Error(`Unknown document type '${typeName}'`); } importPartOfL10n({ from: lib.l10n, fromPath: ["nodeTypes", typeName], to: newSchema.l10n, }); if (jsonData.documentKind !== documentKind) { throw new Error(`Can't import document type "${typeName}" because it is not a "${documentKind}"`); } newSchema.nodeTypes[typeName] = toDocumentType(ctx, { jsonData, languages: newSchema.languages, }); importDocumentTypeDependencies(ctx, jsonData); } function importPartType(ctx, typeName, options = {}) { const { newSchema, lib } = ctx; const existingType = newSchema.nodeTypes[typeName]; if (existingType) { if (existingType.kind !== "part") throw new Error(`Duplicated node type '${typeName}'`); if (options.skipDuplicate) return; throw new Error(`Duplicated part type '${typeName}'`); } const jsonData = lib.schema.nodeTypes[typeName]; if (!jsonData || jsonData.kind !== "part") throw new Error(`Unknown part type '${typeName}'`); importPartOfL10n({ from: lib.l10n, fromPath: ["nodeTypes", typeName], to: newSchema.l10n, }); newSchema.nodeTypes[typeName] = toPartType(ctx, jsonData, newSchema.l10n); if (jsonData.mediaPolicy) { importMediaPolicy(ctx, jsonData.mediaPolicy, { skipDuplicate: true }); } } function importMediaPolicy(ctx, policyName, options = {}) { const { newSchema, lib } = ctx; if (newSchema.mediaPolicies[policyName]) { if (options.skipDuplicate) return; throw new Error(`Duplicate media policy '${policyName}'`); } const jsonData = ctx.mediaPoliciesJsonData.get(policyName) ?? lib.schema.mediaPolicies[policyName]; if (!jsonData) throw new Error(`Unknown media policy '${policyName}'`); newSchema.mediaPolicies[policyName] = jsonData; } async function readJtSiteSchema(file, siteSchemaVersion) { const json = await readFile(file, { encoding: "utf8", }); const data = JSON.parse(json); const ver = data.ParoiCMSSiteSchemaFormatVersion ?? (data.schemaEngineVersion === "1.0.0" ? "1.0.0" : data.version); checkSiteSchemaVersion(ver, { file }, siteSchemaVersion); const result = typeValidator.validate("JtSiteSchema", data); if (!result.valid) { throw new Error(`invalid site-schema '${file}': ${result.error ?? "(missing message)"}`); } return data; } function importFieldType(ctx, { fieldName, fillL10n, fillL10nPath, }) { const { lib } = ctx; if (!(fieldName in lib.schema.fieldTypes)) { throw new Error(`unknown field '${fieldName}'`); } const field = toFieldType(ctx, lib.schema.fieldTypes[fieldName]); importPartOfL10n({ from: lib.l10n, fromPath: ["fieldTypes", fieldName], to: fillL10n, toPath: [...fillL10nPath, fieldName], }); return field; } function toFieldType(ctx, jsonData) { const { plugin, ...rawField } = jsonData; const field = rawField.storedAs === "labeling" ? { ...rawField, dataType: "labeling", } : { ...rawField, }; if (plugin) { if (!ctx.lib.plugins?.has(plugin)) { throw new Error(`[${jsonData.name}] Unknown plugin '${plugin}'`); } field.pluginName = plugin; } if (jsonData.storedAs === "labeling") { ctx.importTypeNames.push({ typeName: jsonData.taxonomy, kind: "document", documentKind: "routing", }); } return field; } function checkDuplicatedFieldNames(fields) { const names = fields.map((item) => item.name); const set = new Set(names); if (set.size !== fields.length) throw new Error(`duplicated fields: ${names.join(", ")}`); } function checkSiteFieldNames(fields, lib) { const names = fields.map((item) => item.name); const set = new Set(names); if (set.size !== fields.length) throw new Error(`duplicated fields: ${names.join(", ")}`); const invalidNames = lib.common.siteFields.map((item) => item.name); invalidNames.push(...invalidFieldNames); for (const invalidName of invalidNames) { if (set.has(invalidName)) throw new Error(`invalid field name "${invalidName}"`); } } function camelToKebabCase(s) { return s .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) .replace(/^-+/, "") .replace(/-+$/, "") .replace(/--+/g, "-"); } //# sourceMappingURL=site-schema-factory.js.map