UNPKG

@paroicms/server

Version:
722 lines 28.7 kB
import { PART_DEFAULT_LIST_NAME } from "@paroicms/internal-anywhere-lib"; import { ensureLanguagesFormat, jsonTypeValidator, parseSorting, siteSchemaFormatVersion, } from "@paroicms/public-server-lib"; import { type } from "arktype"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { defaultImageQualityPolicy, defaultMediaPolicy } from "./default-media-policy.js"; import { deriveOgTypeFromJsonLdType } from "./derive-og-type.js"; import { completeSchemaLocales, createEmptySchemaLocales, importPartOfL10n, makeSiteLanguageLabels, } from "./read-locales.js"; import { prepareLib } from "./schema-lib.js"; import { checkSiteSchemaFormatVersion } from "./site-schema-helpers.js"; const StringAT = type("string"); const authorizedClassicRoute = new Set([ ":relativeId-:slug", ":yyyy/:mm/:dd/:relativeId-:slug", ":slug", ":relativeId", ]); const invalidFieldNames = ["featuredImage", "adminUiFavicon"]; export async function loadSiteSchema({ logger, regSite, commonSchemaLib, pluginStaticConfigurations, trusted, }) { const jtRead = await readJtSiteSchema(join(regSite.siteDir, "site-schema.json")); if (!jtRead.upToDate) { return { upToDate: false, siteSchemaFormatVersion: jtRead.siteSchemaFormatVersion, oldSchema: jtRead.jsonData, }; } const { jsonData } = jtRead; 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, home: undefined, }, mediaPolicies: { default: undefined, }, imageQualityPolicy, l10n, }; const ctx = { lib, newSchema, mediaPoliciesJsonData: new Map((jsonData.mediaPolicies ?? []).map((p) => [p.policyName, p])), importTypeNames: [], trusted, }; appendNodeTypesToSiteSchema(ctx, jsonData); importNodeTypesToSiteSchema(ctx); injectAuthorsTaxonomyIfNeeded(ctx); await completeSchemaLocales(regSite.siteDir, l10n); fillLabelingFieldLabelsFromTaxonomies(newSchema); 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 { upToDate: true, 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 injectAuthorsTaxonomyIfNeeded(ctx) { const { newSchema } = ctx; const authorsType = newSchema.nodeTypes.authors; if (authorsType) { if (authorsType.kind !== "document" || authorsType.documentKind !== "routing") { throw new Error("'authors' must be a routing document type"); } if (!authorsType.regularChildren || authorsType.regularChildren.length === 0) { throw new Error("'authors' routing document must have at least one child type"); } } else { importDocumentType(ctx, "author", { documentKind: "regular", skipDuplicate: true }); importDocumentType(ctx, "authors", { documentKind: "routing", skipDuplicate: true }); } const homeType = newSchema.nodeTypes.home; if (!homeType) return; const currentRoutingChildren = homeType.routingChildren ?? []; if (!currentRoutingChildren.includes("authors")) { homeType.routingChildren = [...currentRoutingChildren, "authors"]; } } 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, { qualifiedFieldName: 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, { qualifiedFieldName: 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`); } const derivedOgType = jsonData.ogType ?? deriveOgTypeFromJsonLdType(jsonData.jsonLdType); 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: derivedOgType, jsonLdType: jsonData.jsonLdType, withFeaturedImage: jsonData.withFeaturedImage, cluster: jsonData.cluster, 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.hasFrontendApp && children) { throw new Error(`Document "${jsonData.typeName}" can't have child documents if it is a frontend application`); } const derivedOgType = jsonData.ogType ?? deriveOgTypeFromJsonLdType(jsonData.jsonLdType); return { kind: "document", documentKind: "regular", typeName: jsonData.typeName, kebabName, route: jsonData.route, 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, autoPublish: jsonData.autoPublish, relativeIdGenerator: formatRelativeIdGeneratorSchema(jsonData.relativeIdGenerator, { varName: "relativeIdGenerator", }), templateNames: [kebabName], ogType: derivedOgType, jsonLdType: jsonData.jsonLdType, withFeaturedImage: jsonData.withFeaturedImage, cluster: jsonData.cluster, 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 fillLabelingFieldLabelsFromTaxonomies(siteSchema) { const { nodeTypes, l10n } = siteSchema; for (const nodeType of Object.values(nodeTypes)) { if (nodeType.kind === "site") continue; if (!nodeType.fields) continue; const typeName = nodeType.typeName; for (const field of nodeType.fields) { if (field.dataType !== "labeling") continue; const taxonomyTypeName = field.taxonomy; for (const language of Object.keys(l10n)) { const existingNodeTypeLocales = l10n[language]?.nodeTypes?.[typeName]; const existingFieldsLocales = existingNodeTypeLocales && typeof existingNodeTypeLocales !== "string" ? existingNodeTypeLocales.fields : undefined; const existingFieldLocales = existingFieldsLocales && typeof existingFieldsLocales !== "string" ? existingFieldsLocales[field.name] : undefined; const existingFieldLabel = existingFieldLocales && typeof existingFieldLocales !== "string" ? existingFieldLocales.label : undefined; if (existingFieldLabel) continue; const taxonomyNodeTypeLocales = l10n[language]?.nodeTypes?.[taxonomyTypeName]; const taxonomyLabel = taxonomyNodeTypeLocales && typeof taxonomyNodeTypeLocales !== "string" ? taxonomyNodeTypeLocales.label : undefined; if (!taxonomyLabel) continue; if (!l10n[language].nodeTypes[typeName]) { l10n[language].nodeTypes[typeName] = {}; } const targetNodeTypeLocales = l10n[language].nodeTypes[typeName]; if (typeof targetNodeTypeLocales === "string") continue; if (!targetNodeTypeLocales.fields) { targetNodeTypeLocales.fields = {}; } const targetFieldsLocales = targetNodeTypeLocales.fields; if (typeof targetFieldsLocales === "string") continue; if (!targetFieldsLocales[field.name]) { targetFieldsLocales[field.name] = {}; } const targetFieldLocales = targetFieldsLocales[field.name]; if (typeof targetFieldLocales === "string") continue; targetFieldLocales.label = taxonomyLabel; } } } } 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}'`); } } } } for (const nodeType of Object.values(newSchema.nodeTypes)) { if (nodeType.kind !== "document") continue; validateRoutingClusterConstraints(nodeType, newSchema); } for (const nodeType of Object.values(newSchema.nodeTypes)) { if (nodeType.kind !== "document") continue; if (nodeType.typeName === "home") continue; if (nodeType.routingChildren?.includes("authors")) { throw new Error(`Document type "${nodeType.typeName}" cannot have "authors" as a routing child. Only "home" can have "authors".`); } } } function validateRoutingClusterConstraints(nodeType, schema) { if (nodeType.documentKind === "routing" && nodeType.typeName !== "home" && nodeType.cluster) { throw new Error(`Routing document "${nodeType.typeName}" cannot have cluster property`); } if (nodeType.documentKind === "regular" && nodeType.route === ":yyyy/:mm/:dd/:relativeId-:slug") { if ((nodeType.regularChildren?.length ?? 0) > 0 || (nodeType.routingChildren?.length ?? 0) > 0) { throw new Error(`Regular document "${nodeType.typeName}" with date-based route cannot have children`); } } if (nodeType.routingChildren) { for (const childName of nodeType.routingChildren) { const child = schema.nodeTypes[childName]; if (!child) { throw new Error(`Unknown routing child "${childName}" in "${nodeType.typeName}"`); } if (child.kind !== "document" || child.documentKind !== "routing") { throw new Error(`Child "${childName}" of "${nodeType.typeName}" must be a routing document`); } } } } function toPartType(ctx, jsonData, l10n) { const fields = jsonData.fields === undefined ? undefined : jsonData.fields.map((item) => typeof item === "string" ? importFieldType(ctx, { qualifiedFieldName: 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) { 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); if (typeof ver === "string" && ver !== siteSchemaFormatVersion) { return { upToDate: false, siteSchemaFormatVersion: ver, jsonData: data, }; } checkSiteSchemaFormatVersion(ver, { file }); const result = jsonTypeValidator.validate("JtSiteSchema", data); if (!result.valid) { throw new Error(`invalid site-schema '${file}': ${result.error ?? "(missing message)"}`); } return { upToDate: true, jsonData: data, }; } function importFieldType(ctx, { qualifiedFieldName, fillL10n, fillL10nPath, }) { const { lib } = ctx; if (!(qualifiedFieldName in lib.schema.fieldTypes)) { throw new Error(`unknown field '${qualifiedFieldName}'`); } const field = toFieldType(ctx, lib.schema.fieldTypes[qualifiedFieldName]); importPartOfL10n({ from: lib.l10n, fromPath: ["fieldTypes", qualifiedFieldName], to: fillL10n, toPath: [...fillL10nPath, field.name], }); return field; } function toFieldType(ctx, jsonData) { const { plugin, ...rawField } = jsonData; if (jsonData.name.includes("[") || jsonData.name.includes("]")) { throw new Error(`Invalid field name '${jsonData.name}': field names must not contain '[' or ']' characters. ` + `Use plain name with separate 'plugin' property for plugin fields.`); } const qualifiedName = plugin ? `${jsonData.name}[${plugin}]` : jsonData.name; const field = rawField.storedAs === "labeling" ? { ...rawField, dataType: "labeling", qualifiedName, } : rawField.storedAs === "partField" ? { ...rawField, localized: false, dataType: "partField", qualifiedName, } : { ...rawField, qualifiedName, }; 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", }); } if (jsonData.storedAs === "partField") { if (typeof jsonData.partType !== "string") { throw new Error(`[${jsonData.name}] 'partType' must be a string`); } ctx.importTypeNames.push({ typeName: jsonData.partType, kind: "part" }); } return field; } function checkDuplicatedFieldNames(fields) { const names = fields.map((item) => item.name); const set = new Set(names); if (set.size !== fields.length) { const details = fields .map((f) => (f.qualifiedName !== f.name ? `${f.name}${f.qualifiedName}` : f.name)) .join(", "); throw new Error(`duplicated fields: ${details}`); } } 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