@conradsix/c4m-schema 0.0.0-stage → 0.1.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/defineBlock.d.ts +25 -0
- package/dist/defineBlock.d.ts.map +1 -0
- package/dist/defineBlock.js +71 -0
- package/dist/defineBlock.js.map +1 -0
- package/dist/defineCatalogue.d.ts +42 -0
- package/dist/defineCatalogue.d.ts.map +1 -0
- package/dist/defineCatalogue.js +52 -0
- package/dist/defineCatalogue.js.map +1 -0
- package/dist/descriptor.d.ts +8 -0
- package/dist/descriptor.d.ts.map +1 -0
- package/dist/descriptor.js +2 -0
- package/dist/descriptor.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/isJsonEqual.d.ts +3 -0
- package/dist/isJsonEqual.d.ts.map +1 -0
- package/dist/isJsonEqual.js +26 -0
- package/dist/isJsonEqual.js.map +1 -0
- package/dist/markdownPageRef.d.ts +5 -0
- package/dist/markdownPageRef.d.ts.map +1 -0
- package/dist/markdownPageRef.js +28 -0
- package/dist/markdownPageRef.js.map +1 -0
- package/dist/menuActions.d.ts +61 -0
- package/dist/menuActions.d.ts.map +1 -0
- package/dist/menuActions.js +32 -0
- package/dist/menuActions.js.map +1 -0
- package/dist/pageSeo.d.ts +19 -0
- package/dist/pageSeo.d.ts.map +1 -0
- package/dist/pageSeo.js +18 -0
- package/dist/pageSeo.js.map +1 -0
- package/dist/references.d.ts +22 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +12 -0
- package/dist/references.js.map +1 -0
- package/dist/syncSchemas.d.ts +28 -0
- package/dist/syncSchemas.d.ts.map +1 -0
- package/dist/syncSchemas.js +50 -0
- package/dist/syncSchemas.js.map +1 -0
- package/package.json +45 -3
- package/src/defineBlock.ts +153 -0
- package/src/defineCatalogue.ts +118 -0
- package/src/descriptor.ts +10 -0
- package/src/index.ts +24 -0
- package/src/isJsonEqual.ts +26 -0
- package/src/markdownPageRef.ts +30 -0
- package/src/menuActions.ts +50 -0
- package/src/pageSeo.ts +23 -0
- package/src/references.ts +21 -0
- package/src/syncSchemas.ts +84 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CmsSchemaDescriptor } from "./descriptor.js";
|
|
2
|
+
type RegisteredSchema = {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly version: number;
|
|
5
|
+
readonly jsonSchema: string;
|
|
6
|
+
};
|
|
7
|
+
type SchemaRegistry = {
|
|
8
|
+
list(): Promise<readonly RegisteredSchema[]>;
|
|
9
|
+
create(schema: RegisteredSchema): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
type SyncResult = {
|
|
12
|
+
readonly created: Array<{
|
|
13
|
+
name: string;
|
|
14
|
+
version: number;
|
|
15
|
+
}>;
|
|
16
|
+
readonly validated: Array<{
|
|
17
|
+
name: string;
|
|
18
|
+
version: number;
|
|
19
|
+
}>;
|
|
20
|
+
readonly errors: Array<{
|
|
21
|
+
type: "mismatch" | "orphaned" | "creation";
|
|
22
|
+
details: string;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
declare const syncSchemas: (descriptors: readonly CmsSchemaDescriptor[], registry: SchemaRegistry) => Promise<SyncResult>;
|
|
26
|
+
export { syncSchemas };
|
|
27
|
+
export type { RegisteredSchema, SchemaRegistry, SyncResult };
|
|
28
|
+
//# sourceMappingURL=syncSchemas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"syncSchemas.d.ts","sourceRoot":"","sources":["../src/syncSchemas.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAM3D,KAAK,gBAAgB,GAAG;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B,CAAC;AAGF,KAAK,cAAc,GAAG;IACpB,IAAI,IAAI,OAAO,CAAC,SAAS,gBAAgB,EAAE,CAAC,CAAC;IAC7C,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD,CAAC;AAEF,KAAK,UAAU,GAAG;IAChB,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,UAAU,GAAG,UAAU,GAAG,UAAU,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACzF,CAAC;AAOF,QAAA,MAAM,WAAW,gBACF,SAAS,mBAAmB,EAAE,YACjC,cAAc,KACvB,OAAO,CAAC,UAAU,CAgDpB,CAAC;AAEF,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,UAAU,EAAE,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isJsonEqual } from "./isJsonEqual.js";
|
|
3
|
+
const keyOf = (schema) => `${schema.name}:${schema.version}`;
|
|
4
|
+
// Registers the schemas the registry lacks and checks the rest are unchanged. A registered schema never changes, so
|
|
5
|
+
// a different one, or one the host no longer has, fails the sync.
|
|
6
|
+
const syncSchemas = async (descriptors, registry) => {
|
|
7
|
+
if (descriptors.length === 0)
|
|
8
|
+
throw new Error("No local schemas to sync");
|
|
9
|
+
const local = new Map(descriptors.map((descriptor) => [
|
|
10
|
+
keyOf(descriptor),
|
|
11
|
+
{ ...descriptor, jsonSchema: JSON.stringify(z.toJSONSchema(descriptor.schema)) },
|
|
12
|
+
]));
|
|
13
|
+
const registered = new Map((await registry.list()).map((schema) => [keyOf(schema), schema]));
|
|
14
|
+
const result = { created: [], validated: [], errors: [] };
|
|
15
|
+
for (const [key, schema] of registered)
|
|
16
|
+
if (!local.has(key))
|
|
17
|
+
result.errors.push({
|
|
18
|
+
type: "orphaned",
|
|
19
|
+
details: `The CMS has '${schema.name}' version ${schema.version}, which doesn't exist locally`,
|
|
20
|
+
});
|
|
21
|
+
for (const [key, schema] of local) {
|
|
22
|
+
const existing = registered.get(key);
|
|
23
|
+
const entry = { name: schema.name, version: schema.version };
|
|
24
|
+
if (existing) {
|
|
25
|
+
if (isJsonEqual(schema.jsonSchema, existing.jsonSchema))
|
|
26
|
+
result.validated.push(entry);
|
|
27
|
+
else
|
|
28
|
+
result.errors.push({
|
|
29
|
+
type: "mismatch",
|
|
30
|
+
details: `Schema mismatch for '${schema.name}' version ${schema.version}: local and CMS schemas differ`,
|
|
31
|
+
});
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await registry.create({ ...entry, jsonSchema: schema.jsonSchema });
|
|
36
|
+
result.created.push(entry);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
result.errors.push({
|
|
40
|
+
type: "creation",
|
|
41
|
+
details: `Failed to create '${schema.name}' version ${schema.version}: ${String(error)}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (result.errors.length > 0)
|
|
46
|
+
throw new Error(`Schema sync failed:\n${result.errors.map((error) => `${error.type.toUpperCase()}: ${error.details}`).join("\n")}`);
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
export { syncSchemas };
|
|
50
|
+
//# sourceMappingURL=syncSchemas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"syncSchemas.js","sourceRoot":"","sources":["../src/syncSchemas.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAoB/C,MAAM,KAAK,GAAG,CAAC,MAA2D,EAAU,EAAE,CACpF,GAAG,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;AAErC,oHAAoH;AACpH,kEAAkE;AAClE,MAAM,WAAW,GAAG,KAAK,EACvB,WAA2C,EAC3C,QAAwB,EACH,EAAE;IACvB,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC;IAE1E,MAAM,KAAK,GAAG,IAAI,GAAG,CACnB,WAAW,CAAC,GAAG,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC;QAC9B,KAAK,CAAC,UAAU,CAAC;QACjB,EAAE,GAAG,UAAU,EAAE,UAAU,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,YAAY,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,EAAE;KACjF,CAAC,CACH,CAAC;IACF,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAC7F,MAAM,MAAM,GAAe,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IAEtE,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,UAAU;QACpC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC;YACjB,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;gBACjB,IAAI,EAAE,UAAU;gBAChB,OAAO,EAAE,gBAAgB,MAAM,CAAC,IAAI,aAAa,MAAM,CAAC,OAAO,+BAA+B;aAC/F,CAAC,CAAC;IAEP,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QAClC,MAAM,QAAQ,GAAG,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACrC,MAAM,KAAK,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC;QAC7D,IAAI,QAAQ,EAAE,CAAC;YACb,IAAI,WAAW,CAAC,MAAM,CAAC,UAAU,EAAE,QAAQ,CAAC,UAAU,CAAC;gBAAE,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;;gBAEpF,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;oBACjB,IAAI,EAAE,UAAU;oBAChB,OAAO,EAAE,wBAAwB,MAAM,CAAC,IAAI,aAAa,MAAM,CAAC,OAAO,gCAAgC;iBACxG,CAAC,CAAC;YACL,SAAS;QACX,CAAC;QAED,IAAI,CAAC;YACH,MAAM,QAAQ,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC;YACnE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC7B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;gBACjB,IAAI,EAAE,UAAU;gBAChB,OAAO,EAAE,qBAAqB,MAAM,CAAC,IAAI,aAAa,MAAM,CAAC,OAAO,KAAK,MAAM,CAAC,KAAK,CAAC,EAAE;aACzF,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,wBAAwB,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CACnH,CAAC;IACJ,OAAO,MAAM,CAAC;AAChB,CAAC,CAAC;AAEF,OAAO,EAAE,WAAW,EAAE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conradsix/c4m-schema",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-preview.1",
|
|
4
|
+
"description": "Versioned block and menu action schemas for C4M, defined with zod, and their sync with the engine.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"c4m",
|
|
7
|
+
"cms",
|
|
8
|
+
"zod",
|
|
9
|
+
"json-schema"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "Brian Tyler",
|
|
13
|
+
"homepage": "https://github.com/conradsix/c4m#readme",
|
|
14
|
+
"bugs": "https://github.com/conradsix/c4m/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/conradsix/c4m.git",
|
|
18
|
+
"directory": "typescript/packages/c4m-schema"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"!src/**/*.test.ts"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.build.json",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"check:types": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"zod": "^4.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "26.6.5",
|
|
43
|
+
"typescript": "7.0.2",
|
|
44
|
+
"vite": "8.3.5",
|
|
45
|
+
"vitest": "5.0.5",
|
|
46
|
+
"zod": "4.6.7"
|
|
47
|
+
}
|
|
6
48
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// A block version's schema. Its __typename and __version are literals, so stored data names the schema it was saved
|
|
4
|
+
// with.
|
|
5
|
+
type VersionedSchema = z.ZodObject;
|
|
6
|
+
|
|
7
|
+
// Data at one version, as the next.
|
|
8
|
+
type Upgrade<TFrom extends VersionedSchema, TTo extends VersionedSchema> = (
|
|
9
|
+
block: z.output<TFrom>,
|
|
10
|
+
) => z.input<TTo>;
|
|
11
|
+
|
|
12
|
+
type BlockDefinition<
|
|
13
|
+
TName extends string,
|
|
14
|
+
TSchemas extends readonly VersionedSchema[],
|
|
15
|
+
TLatest extends VersionedSchema,
|
|
16
|
+
> = {
|
|
17
|
+
readonly name: TName;
|
|
18
|
+
// Every version, oldest first.
|
|
19
|
+
readonly schemas: TSchemas;
|
|
20
|
+
readonly latest: TLatest;
|
|
21
|
+
// Data stored at any version, as the latest: through the legacy upgrade, then each later version's step. Returns new
|
|
22
|
+
// data and leaves its input alone, even when the input is already the latest version.
|
|
23
|
+
upgrade(block: z.output<TSchemas[number]>): z.output<TLatest>;
|
|
24
|
+
// Adds the next version; its from takes the step that brings the current latest to it.
|
|
25
|
+
version<TNext extends VersionedSchema>(
|
|
26
|
+
schema: TNext,
|
|
27
|
+
): NextVersion<TName, TSchemas, TLatest, TNext>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type NextVersion<
|
|
31
|
+
TName extends string,
|
|
32
|
+
TSchemas extends readonly VersionedSchema[],
|
|
33
|
+
TLatest extends VersionedSchema,
|
|
34
|
+
TNext extends VersionedSchema,
|
|
35
|
+
> = {
|
|
36
|
+
from(
|
|
37
|
+
upgrade: Upgrade<TLatest, TNext>,
|
|
38
|
+
): BlockDefinition<TName, readonly [...TSchemas, TNext], TNext>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type Legacy<TVersions extends readonly VersionedSchema[]> = {
|
|
42
|
+
// The versions from before the baseline, oldest first.
|
|
43
|
+
readonly versions: TVersions;
|
|
44
|
+
// Data stored at any of them, as the baseline. The catalogue sets the baseline's version but doesn't check the result
|
|
45
|
+
// against its schema: it is taken as it is.
|
|
46
|
+
readonly upgrade: (block: z.output<TVersions[number]>) => object;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type AnyUpgrade = (block: never) => unknown;
|
|
50
|
+
|
|
51
|
+
type Chain = {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly schemas: readonly VersionedSchema[];
|
|
54
|
+
readonly baseline: number;
|
|
55
|
+
readonly legacy: ((block: never) => object) | undefined;
|
|
56
|
+
// steps[i] takes version baseline + i to the next.
|
|
57
|
+
readonly steps: readonly AnyUpgrade[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const literalOf = (schema: VersionedSchema, key: "__typename" | "__version"): unknown => {
|
|
61
|
+
const field = (schema.shape as Readonly<Record<string, unknown>>)[key];
|
|
62
|
+
return field instanceof z.ZodLiteral && field.values.size === 1
|
|
63
|
+
? [...field.values][0]
|
|
64
|
+
: undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const versionOf = (name: string, schema: VersionedSchema): number => {
|
|
68
|
+
const version = literalOf(schema, "__version");
|
|
69
|
+
if (literalOf(schema, "__typename") !== name || typeof version !== "number")
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Every ${name} schema needs the literals __typename "${name}" and a __version number`,
|
|
72
|
+
);
|
|
73
|
+
return version;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const itemAt = <T>(items: readonly T[], index: number, name: string): T => {
|
|
77
|
+
const item = items[index];
|
|
78
|
+
if (item === undefined) throw new Error(`${name} has no version ${index + 1}`);
|
|
79
|
+
return item;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const upgradeChain = (chain: Chain, block: { readonly __version: number }): unknown => {
|
|
83
|
+
const latest = chain.schemas.length;
|
|
84
|
+
let version = block.__version;
|
|
85
|
+
if (!Number.isInteger(version) || version < 1 || version > latest)
|
|
86
|
+
throw new Error(`${chain.name} has no version ${String(version)}`);
|
|
87
|
+
let data: unknown = block;
|
|
88
|
+
if (version < chain.baseline) {
|
|
89
|
+
if (!chain.legacy) throw new Error(`${chain.name} has no legacy upgrade`);
|
|
90
|
+
data = { ...chain.legacy(data as never), __version: chain.baseline };
|
|
91
|
+
version = chain.baseline;
|
|
92
|
+
}
|
|
93
|
+
for (; version < latest; version++) {
|
|
94
|
+
const step = itemAt(chain.steps, version - chain.baseline, chain.name);
|
|
95
|
+
data = itemAt(chain.schemas, version, chain.name).parse(step(data as never));
|
|
96
|
+
}
|
|
97
|
+
return data;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const build = (
|
|
101
|
+
chain: Chain,
|
|
102
|
+
): BlockDefinition<string, readonly VersionedSchema[], VersionedSchema> => ({
|
|
103
|
+
name: chain.name,
|
|
104
|
+
schemas: chain.schemas,
|
|
105
|
+
latest: itemAt(chain.schemas, chain.schemas.length - 1, chain.name),
|
|
106
|
+
upgrade: (block) =>
|
|
107
|
+
upgradeChain(
|
|
108
|
+
chain,
|
|
109
|
+
structuredClone(block) as { readonly __version: number },
|
|
110
|
+
) as z.output<VersionedSchema>,
|
|
111
|
+
version: <TNext extends VersionedSchema>(
|
|
112
|
+
schema: TNext,
|
|
113
|
+
): NextVersion<string, readonly VersionedSchema[], VersionedSchema, TNext> => {
|
|
114
|
+
if (versionOf(chain.name, schema) !== chain.schemas.length + 1)
|
|
115
|
+
throw new Error(`The next ${chain.name} version is ${chain.schemas.length + 1}`);
|
|
116
|
+
return {
|
|
117
|
+
from: (upgrade) =>
|
|
118
|
+
build({
|
|
119
|
+
...chain,
|
|
120
|
+
schemas: [...chain.schemas, schema],
|
|
121
|
+
steps: [...chain.steps, upgrade],
|
|
122
|
+
}) as BlockDefinition<string, readonly [...VersionedSchema[], TNext], TNext>,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// A block type and every version it has had. Versions from before the baseline upgrade to it through the legacy
|
|
128
|
+
// upgrade; each version added after it brings its own step.
|
|
129
|
+
const defineBlock = <
|
|
130
|
+
TName extends string,
|
|
131
|
+
TBaseline extends VersionedSchema,
|
|
132
|
+
const TLegacy extends readonly VersionedSchema[] = readonly [],
|
|
133
|
+
>(
|
|
134
|
+
name: TName,
|
|
135
|
+
definition: { readonly baseline: TBaseline; readonly legacy?: Legacy<TLegacy> },
|
|
136
|
+
): BlockDefinition<TName, readonly [...TLegacy, TBaseline], TBaseline> => {
|
|
137
|
+
const schemas = [...(definition.legacy?.versions ?? []), definition.baseline];
|
|
138
|
+
for (const [index, schema] of schemas.entries())
|
|
139
|
+
if (versionOf(name, schema) !== index + 1)
|
|
140
|
+
throw new Error(
|
|
141
|
+
`${name} versions must run from 1 without gaps; version ${index + 1} is missing`,
|
|
142
|
+
);
|
|
143
|
+
return build({
|
|
144
|
+
name,
|
|
145
|
+
schemas,
|
|
146
|
+
baseline: schemas.length,
|
|
147
|
+
legacy: definition.legacy?.upgrade,
|
|
148
|
+
steps: [],
|
|
149
|
+
}) as BlockDefinition<TName, readonly [...TLegacy, TBaseline], TBaseline>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export { defineBlock, versionOf };
|
|
153
|
+
export type { BlockDefinition, Upgrade, VersionedSchema };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { BlockDefinition, VersionedSchema } from "./defineBlock.js";
|
|
2
|
+
import type { CmsSchemaDescriptor } from "./descriptor.js";
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { versionOf } from "./defineBlock.js";
|
|
7
|
+
|
|
8
|
+
type AnyBlock = BlockDefinition<string, readonly VersionedSchema[], VersionedSchema>;
|
|
9
|
+
|
|
10
|
+
type VersionOf<TSchema> = TSchema extends VersionedSchema
|
|
11
|
+
? z.output<TSchema> extends { readonly __version: infer TVersion }
|
|
12
|
+
? TVersion
|
|
13
|
+
: number
|
|
14
|
+
: never;
|
|
15
|
+
|
|
16
|
+
type StoredSchemaOf<TBlocks extends readonly AnyBlock[]> = TBlocks[number]["schemas"][number];
|
|
17
|
+
|
|
18
|
+
type LatestSchemas<TBlocks extends readonly AnyBlock[]> = {
|
|
19
|
+
-readonly [K in keyof TBlocks]: TBlocks[K]["latest"];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type DescriptorsOf<TBlock extends AnyBlock> = {
|
|
23
|
+
[K in keyof TBlock["schemas"]]: {
|
|
24
|
+
readonly name: TBlock["name"];
|
|
25
|
+
readonly version: VersionOf<TBlock["schemas"][K]>;
|
|
26
|
+
readonly schema: TBlock["schemas"][K];
|
|
27
|
+
};
|
|
28
|
+
}[number];
|
|
29
|
+
|
|
30
|
+
type CurrentDescriptors<TBlocks extends readonly AnyBlock[]> = {
|
|
31
|
+
readonly [K in keyof TBlocks]: {
|
|
32
|
+
readonly name: TBlocks[K]["name"];
|
|
33
|
+
readonly version: VersionOf<TBlocks[K]["latest"]>;
|
|
34
|
+
readonly schema: TBlocks[K]["latest"];
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type StoredUnion<TBlocks extends readonly AnyBlock[]> = z.ZodUnion<StoredSchemaOf<TBlocks>[]>;
|
|
39
|
+
|
|
40
|
+
type CurrentUnion<TBlocks extends readonly AnyBlock[]> = z.ZodUnion<LatestSchemas<TBlocks>>;
|
|
41
|
+
|
|
42
|
+
type BlockCatalogue<TBlocks extends readonly AnyBlock[]> = {
|
|
43
|
+
readonly blocks: TBlocks;
|
|
44
|
+
// Every version of every block: what the CMS registers.
|
|
45
|
+
readonly descriptors: readonly DescriptorsOf<TBlocks[number]>[];
|
|
46
|
+
// Each block's latest version, in catalogue order.
|
|
47
|
+
readonly current: CurrentDescriptors<TBlocks>;
|
|
48
|
+
// Data stored at any version of any block.
|
|
49
|
+
readonly Stored: StoredUnion<TBlocks>;
|
|
50
|
+
// Any block at its latest version.
|
|
51
|
+
readonly Current: CurrentUnion<TBlocks>;
|
|
52
|
+
// Stored data, upgraded to its block's latest version.
|
|
53
|
+
readonly Block: z.ZodPipe<
|
|
54
|
+
StoredUnion<TBlocks>,
|
|
55
|
+
z.ZodTransform<z.output<CurrentUnion<TBlocks>>, z.output<StoredUnion<TBlocks>>>
|
|
56
|
+
>;
|
|
57
|
+
// Upgrades data that names its block and version. Throws for a block or version the catalogue doesn't have.
|
|
58
|
+
readonly upgrade: (block: {
|
|
59
|
+
readonly __typename: string;
|
|
60
|
+
readonly __version: number;
|
|
61
|
+
}) => z.output<CurrentUnion<TBlocks>>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The blocks a host stores. Each block's name may appear once.
|
|
65
|
+
const defineCatalogue = <const TBlocks extends readonly AnyBlock[]>(
|
|
66
|
+
blocks: TBlocks,
|
|
67
|
+
): BlockCatalogue<TBlocks> => {
|
|
68
|
+
const byName = new Map<string, AnyBlock>();
|
|
69
|
+
for (const block of blocks) {
|
|
70
|
+
if (byName.has(block.name)) throw new Error(`The catalogue has ${block.name} twice`);
|
|
71
|
+
byName.set(block.name, block);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const descriptors: CmsSchemaDescriptor[] = blocks.flatMap((block) =>
|
|
75
|
+
block.schemas.map((schema) => ({
|
|
76
|
+
name: block.name,
|
|
77
|
+
version: versionOf(block.name, schema),
|
|
78
|
+
schema,
|
|
79
|
+
})),
|
|
80
|
+
);
|
|
81
|
+
const current = blocks.map((block) => ({
|
|
82
|
+
name: block.name,
|
|
83
|
+
version: versionOf(block.name, block.latest),
|
|
84
|
+
schema: block.latest,
|
|
85
|
+
}));
|
|
86
|
+
const Stored = z.union(descriptors.map((descriptor) => descriptor.schema));
|
|
87
|
+
const Current = z.union(blocks.map((block) => block.latest));
|
|
88
|
+
|
|
89
|
+
const upgrade = (block: { readonly __typename: string; readonly __version: number }): unknown => {
|
|
90
|
+
const definition = byName.get(block.__typename);
|
|
91
|
+
if (!definition) throw new Error(`Unknown block ${block.__typename}`);
|
|
92
|
+
return definition.upgrade(block);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const Block = Stored.transform((block, ctx) => {
|
|
96
|
+
try {
|
|
97
|
+
return upgrade(block as { readonly __typename: string; readonly __version: number });
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof z.ZodError)) throw error;
|
|
100
|
+
for (const issue of error.issues)
|
|
101
|
+
ctx.issues.push({ code: "custom", message: issue.message, path: issue.path, input: block });
|
|
102
|
+
return z.NEVER;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
blocks,
|
|
108
|
+
descriptors,
|
|
109
|
+
current,
|
|
110
|
+
Stored,
|
|
111
|
+
Current,
|
|
112
|
+
Block,
|
|
113
|
+
upgrade,
|
|
114
|
+
} as unknown as BlockCatalogue<TBlocks>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export { defineCatalogue };
|
|
118
|
+
export type { BlockCatalogue };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// One version of a CMS type, as the CMS registers it and checks stored data against it.
|
|
4
|
+
type CmsSchemaDescriptor = {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly version: number;
|
|
7
|
+
readonly schema: z.ZodObject;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type { CmsSchemaDescriptor };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { defineBlock } from "./defineBlock.js";
|
|
2
|
+
export { defineCatalogue } from "./defineCatalogue.js";
|
|
3
|
+
export {
|
|
4
|
+
extractMarkdownPageRefIds,
|
|
5
|
+
fromMarkdownPageRefHref,
|
|
6
|
+
toMarkdownPageRefHref,
|
|
7
|
+
} from "./markdownPageRef.js";
|
|
8
|
+
export {
|
|
9
|
+
defineMenuAction,
|
|
10
|
+
EmptyActionV1Schema,
|
|
11
|
+
ExternalLinkActionV1Schema,
|
|
12
|
+
PageLinkActionV1Schema,
|
|
13
|
+
standardMenuActions,
|
|
14
|
+
} from "./menuActions.js";
|
|
15
|
+
export { PageSeoInputSchema, PublishablePageSeoSchema } from "./pageSeo.js";
|
|
16
|
+
export { ImageRefSchema, PageRefSchema, ResourceRefSchema } from "./references.js";
|
|
17
|
+
export { syncSchemas } from "./syncSchemas.js";
|
|
18
|
+
export type { BlockDefinition, Upgrade, VersionedSchema } from "./defineBlock.js";
|
|
19
|
+
export type { BlockCatalogue } from "./defineCatalogue.js";
|
|
20
|
+
export type { CmsSchemaDescriptor } from "./descriptor.js";
|
|
21
|
+
export type { EmptyActionV1, ExternalLinkActionV1, PageLinkActionV1 } from "./menuActions.js";
|
|
22
|
+
export type { PageSeoInput } from "./pageSeo.js";
|
|
23
|
+
export type { ImageRef, PageRef, ResourceRef } from "./references.js";
|
|
24
|
+
export type { RegisteredSchema, SchemaRegistry, SyncResult } from "./syncSchemas.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const sortKeys = (value: unknown): unknown => {
|
|
2
|
+
if (Array.isArray(value)) {
|
|
3
|
+
return value.map(sortKeys);
|
|
4
|
+
}
|
|
5
|
+
if (value !== null && typeof value === "object") {
|
|
6
|
+
return Object.keys(value)
|
|
7
|
+
.sort()
|
|
8
|
+
.reduce<Record<string, unknown>>((acc, key) => {
|
|
9
|
+
acc[key] = sortKeys((value as Record<string, unknown>)[key]);
|
|
10
|
+
return acc;
|
|
11
|
+
}, {});
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const isJsonEqual = (a: string, b: string): boolean => {
|
|
17
|
+
try {
|
|
18
|
+
const objA = sortKeys(JSON.parse(a));
|
|
19
|
+
const objB = sortKeys(JSON.parse(b));
|
|
20
|
+
return JSON.stringify(objA) === JSON.stringify(objB);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export { isJsonEqual };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PageRefSchema } from "./references.js";
|
|
2
|
+
|
|
3
|
+
const MARKDOWN_PAGE_REF_PREFIX = "page-ref:";
|
|
4
|
+
|
|
5
|
+
const toMarkdownPageRefHref = (pageId: string): string => {
|
|
6
|
+
const result = PageRefSchema.shape.id.safeParse(pageId);
|
|
7
|
+
if (!result.success) throw new Error(`Invalid CMS page ID: ${pageId}`);
|
|
8
|
+
return `${MARKDOWN_PAGE_REF_PREFIX}${result.data}`;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const fromMarkdownPageRefHref = (href: string): string | undefined => {
|
|
12
|
+
if (!href.startsWith(MARKDOWN_PAGE_REF_PREFIX)) return undefined;
|
|
13
|
+
const result = PageRefSchema.shape.id.safeParse(href.slice(MARKDOWN_PAGE_REF_PREFIX.length));
|
|
14
|
+
return result.success ? result.data : undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const extractMarkdownPageRefIds = (markdown: string): string[] => {
|
|
18
|
+
const ids = new Set<string>();
|
|
19
|
+
|
|
20
|
+
for (const match of markdown.matchAll(/page-ref:([0-9a-f-]{36})/giu)) {
|
|
21
|
+
const id = match[1];
|
|
22
|
+
if (!id) continue;
|
|
23
|
+
const result = PageRefSchema.shape.id.safeParse(id);
|
|
24
|
+
if (result.success) ids.add(result.data);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [...ids];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export { extractMarkdownPageRefIds, fromMarkdownPageRefHref, toMarkdownPageRefHref };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { defineBlock } from "./defineBlock.js";
|
|
4
|
+
|
|
5
|
+
// Menu action types version and upgrade as blocks do.
|
|
6
|
+
const defineMenuAction = defineBlock;
|
|
7
|
+
|
|
8
|
+
// A menu item that only opens its children. The engine requires it to have some.
|
|
9
|
+
const EmptyActionV1Schema = z.object({
|
|
10
|
+
__typename: z.literal("empty"),
|
|
11
|
+
__version: z.literal(1),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const PageLinkActionV1Schema = z.object({
|
|
15
|
+
__typename: z.literal("page-link"),
|
|
16
|
+
__version: z.literal(1),
|
|
17
|
+
pageRef: z.object({
|
|
18
|
+
__typename: z.literal("page-ref"),
|
|
19
|
+
id: z.uuid(),
|
|
20
|
+
}),
|
|
21
|
+
openInNewTab: z.boolean().optional().default(false),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ExternalLinkActionV1Schema = z.object({
|
|
25
|
+
__typename: z.literal("external-link"),
|
|
26
|
+
__version: z.literal(1),
|
|
27
|
+
url: z.url("Must be a valid URL"),
|
|
28
|
+
openInNewTab: z.boolean().optional().default(true),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// The menu actions every host has: a heading for children, a link to a page, and a link elsewhere.
|
|
32
|
+
const standardMenuActions = [
|
|
33
|
+
defineMenuAction("empty", { baseline: EmptyActionV1Schema }),
|
|
34
|
+
defineMenuAction("page-link", { baseline: PageLinkActionV1Schema }),
|
|
35
|
+
defineMenuAction("external-link", { baseline: ExternalLinkActionV1Schema }),
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
type EmptyActionV1 = z.infer<typeof EmptyActionV1Schema>;
|
|
39
|
+
type PageLinkActionV1 = z.infer<typeof PageLinkActionV1Schema>;
|
|
40
|
+
type ExternalLinkActionV1 = z.infer<typeof ExternalLinkActionV1Schema>;
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
defineMenuAction,
|
|
44
|
+
EmptyActionV1Schema,
|
|
45
|
+
ExternalLinkActionV1Schema,
|
|
46
|
+
PageLinkActionV1Schema,
|
|
47
|
+
standardMenuActions,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type { EmptyActionV1, ExternalLinkActionV1, PageLinkActionV1 };
|
package/src/pageSeo.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const PageSeoInputSchema = z.object({
|
|
4
|
+
title: z.string().trim().max(60, "Use 60 characters or fewer"),
|
|
5
|
+
description: z.string().trim().max(160, "Use 160 characters or fewer"),
|
|
6
|
+
socialImageId: z.uuid().nullable(),
|
|
7
|
+
index: z.boolean(),
|
|
8
|
+
follow: z.boolean(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const PublishablePageSeoSchema = PageSeoInputSchema.extend({
|
|
12
|
+
title: z.string().trim().min(1, "Enter an SEO title").max(60, "Use 60 characters or fewer"),
|
|
13
|
+
description: z
|
|
14
|
+
.string()
|
|
15
|
+
.trim()
|
|
16
|
+
.min(1, "Enter an SEO description")
|
|
17
|
+
.max(160, "Use 160 characters or fewer"),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
type PageSeoInput = z.infer<typeof PageSeoInputSchema>;
|
|
21
|
+
|
|
22
|
+
export { PageSeoInputSchema, PublishablePageSeoSchema };
|
|
23
|
+
export type { PageSeoInput };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const ImageRefSchema = z.object({
|
|
4
|
+
__typename: z.literal("image-ref"),
|
|
5
|
+
id: z.uuid(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const PageRefSchema = z.object({
|
|
9
|
+
__typename: z.literal("page-ref"),
|
|
10
|
+
id: z.uuid(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const ResourceRefSchema = z.union([ImageRefSchema, PageRefSchema]);
|
|
14
|
+
|
|
15
|
+
type ImageRef = z.infer<typeof ImageRefSchema>;
|
|
16
|
+
type PageRef = z.infer<typeof PageRefSchema>;
|
|
17
|
+
type ResourceRef = z.infer<typeof ResourceRefSchema>;
|
|
18
|
+
|
|
19
|
+
export { ImageRefSchema, PageRefSchema, ResourceRefSchema };
|
|
20
|
+
|
|
21
|
+
export type { ImageRef, PageRef, ResourceRef };
|