@paroicms/connector
Version:
An API to help build management tools for ParoiCMS instances
558 lines • 21.5 kB
JavaScript
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