@backmatter/core 0.0.17 → 0.0.19
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/package.json +1 -1
- package/src/block.ts +14 -3
- package/src/config.ts +4 -0
- package/src/content-committed.ts +16 -0
- package/src/index.ts +17 -1
- package/src/model.ts +72 -2
- package/src/node/cli.ts +24 -3
- package/src/node/discover.ts +5 -0
- package/src/node/eval-model.ts +105 -17
- package/src/node/generate.ts +67 -2
- package/src/node/index.ts +1 -1
- package/src/preview-channel.ts +255 -0
- package/src/preview-client.ts +291 -0
- package/src/preview-session.ts +107 -0
- package/src/preview.ts +76 -1
- package/src/source.ts +24 -0
package/package.json
CHANGED
package/src/block.ts
CHANGED
|
@@ -39,9 +39,17 @@ export function defineBlock<const D extends BlockDef>(def: D): D {
|
|
|
39
39
|
if (def.component === undefined) {
|
|
40
40
|
throw new Error("block.component is required");
|
|
41
41
|
}
|
|
42
|
+
if (RESERVED_BLOCK_PROP in def.fields) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`block.fields.${RESERVED_BLOCK_PROP} is reserved: it carries the external source record`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
42
47
|
return def;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/** Prop name the renderer uses for an external model's record. */
|
|
51
|
+
export const RESERVED_BLOCK_PROP = "data";
|
|
52
|
+
|
|
45
53
|
type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
|
|
46
54
|
|
|
47
55
|
type ScalarOf<K extends FieldKind> = K extends "number"
|
|
@@ -58,8 +66,11 @@ type IsRequired<F> = F extends { required?: infer R }
|
|
|
58
66
|
: false
|
|
59
67
|
: false;
|
|
60
68
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
69
|
+
/**
|
|
70
|
+
* TypeScript shape of a block's props, derived from `field.*` calls. Pass the
|
|
71
|
+
* record type as `Data` for blocks on an `external` model to type `props.data`.
|
|
72
|
+
*/
|
|
73
|
+
export type InferBlockProps<Fields extends Record<string, FieldDef>, Data = undefined> = Simplify<
|
|
63
74
|
{
|
|
64
75
|
[N in keyof Fields as IsRequired<Fields[N]> extends true ? N : never]: ScalarOf<
|
|
65
76
|
Fields[N]["kind"]
|
|
@@ -68,7 +79,7 @@ export type InferBlockProps<Fields extends Record<string, FieldDef>> = Simplify<
|
|
|
68
79
|
[N in keyof Fields as IsRequired<Fields[N]> extends true ? never : N]?: ScalarOf<
|
|
69
80
|
Fields[N]["kind"]
|
|
70
81
|
>;
|
|
71
|
-
}
|
|
82
|
+
} & ([Data] extends [undefined] ? {} : { data: Data })
|
|
72
83
|
>;
|
|
73
84
|
|
|
74
85
|
export function serializeBlock(block: BlockDef): BlockSnapshot {
|
package/src/config.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type BackmatterDirs = {
|
|
|
16
16
|
layouts?: string;
|
|
17
17
|
/** Folder with one `defineObject` default export per file. Default: "objects". */
|
|
18
18
|
objects?: string;
|
|
19
|
+
/** Folder with one `defineSource` default export per external model type. Default: "sources". */
|
|
20
|
+
sources?: string;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export type BackmatterConfig = {
|
|
@@ -57,6 +59,7 @@ export const DEFAULT_DIRS: Required<BackmatterDirs> = {
|
|
|
57
59
|
mdx: "mdx",
|
|
58
60
|
layouts: "layouts",
|
|
59
61
|
objects: "objects",
|
|
62
|
+
sources: "sources",
|
|
60
63
|
};
|
|
61
64
|
export const DEFAULT_LOCALE = "en";
|
|
62
65
|
export const CLOUD_API_URL = "https://api.backmatter.app";
|
|
@@ -190,6 +193,7 @@ export function resolveConfig(
|
|
|
190
193
|
mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
|
|
191
194
|
layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
|
|
192
195
|
objects: config.dirs?.objects ?? DEFAULT_DIRS.objects,
|
|
196
|
+
sources: config.dirs?.sources ?? DEFAULT_DIRS.sources,
|
|
193
197
|
},
|
|
194
198
|
};
|
|
195
199
|
}
|
package/src/content-committed.ts
CHANGED
|
@@ -40,6 +40,22 @@ export function listCacheTags(type?: string): string[] {
|
|
|
40
40
|
return type ? [BACKMATTER_CACHE_TAG, `backmatter:${type}`] : [BACKMATTER_CACHE_TAG];
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Cache tags a commit should expire. A normal commit expires the touched
|
|
45
|
+
* documents and their type lists. A full rebuild expires the global tag,
|
|
46
|
+
* which every cached entry carries.
|
|
47
|
+
*/
|
|
48
|
+
export function revalidateTagsForCommit(event: Pick<ContentCommittedEvent, "docs" | "full">): string[] {
|
|
49
|
+
if (event.full) return [BACKMATTER_CACHE_TAG];
|
|
50
|
+
const tags = new Set<string>();
|
|
51
|
+
for (const doc of event.docs ?? []) {
|
|
52
|
+
for (const tag of contentCacheTags(doc.locale, doc.type, doc.slug)) {
|
|
53
|
+
if (tag !== BACKMATTER_CACHE_TAG) tags.add(tag);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...tags];
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
/** Site paths the host should `revalidatePath` for these docs. */
|
|
44
60
|
export function pathsForCommittedDocs(
|
|
45
61
|
models: readonly RouteModel[],
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,9 @@ export {
|
|
|
24
24
|
defaultModelPath,
|
|
25
25
|
DEFAULT_MODEL_PATH,
|
|
26
26
|
DEFAULT_PAGE_PATH,
|
|
27
|
+
DEFAULT_TEMPLATE_SLUG,
|
|
27
28
|
IMPLICIT_SINGLETON_SLUG,
|
|
29
|
+
isTemplateSlug,
|
|
28
30
|
serializeModel,
|
|
29
31
|
serializeSchema,
|
|
30
32
|
parseSchemaSnapshot,
|
|
@@ -40,8 +42,12 @@ export {
|
|
|
40
42
|
defineBlock,
|
|
41
43
|
serializeBlock,
|
|
42
44
|
parseBlockSnapshot,
|
|
45
|
+
RESERVED_BLOCK_PROP,
|
|
43
46
|
} from "./block";
|
|
44
47
|
|
|
48
|
+
export type { SourceDef, SourceRef } from "./source";
|
|
49
|
+
export { defineSource } from "./source";
|
|
50
|
+
|
|
45
51
|
export type { DocFormat } from "./doc-format";
|
|
46
52
|
export { modelFormat, parseDoc, serializeDoc } from "./doc-format";
|
|
47
53
|
|
|
@@ -118,21 +124,30 @@ export {
|
|
|
118
124
|
export type { BackmatterDoc, BackmatterDocSummary } from "./doc";
|
|
119
125
|
export { createQuery } from "./query";
|
|
120
126
|
export type { Query, ModelQuery, TypedDoc, Where, Include, DocLoader } from "./query";
|
|
121
|
-
export type { PreviewCheckout, PreviewTicket } from "./preview-session";
|
|
127
|
+
export type { PreviewCheckout, PreviewSessionClaims, PreviewTicket } from "./preview-session";
|
|
122
128
|
export {
|
|
123
129
|
PREVIEW_COOKIE,
|
|
130
|
+
PREVIEW_SESSION_COOKIE,
|
|
131
|
+
PREVIEW_SESSION_QUERY,
|
|
132
|
+
PREVIEW_SESSION_STORAGE_KEY,
|
|
124
133
|
PREVIEW_ENABLE_PATH,
|
|
125
134
|
PREVIEW_QUERY,
|
|
126
135
|
PREVIEW_CHECKOUT_QUERY,
|
|
127
136
|
previewLocationWithCheckout,
|
|
128
137
|
isSafeRedirectPath,
|
|
138
|
+
mintPreviewSession,
|
|
129
139
|
mintPreviewTicket,
|
|
140
|
+
peekPreviewSessionWorkspace,
|
|
130
141
|
previewMiddlewareDecision,
|
|
131
142
|
readPreviewCookie,
|
|
143
|
+
readPreviewSession,
|
|
132
144
|
readPreviewTicket,
|
|
145
|
+
scopePreviewAccess,
|
|
146
|
+
takePreviewSessionFromSearch,
|
|
133
147
|
writePreviewCookie,
|
|
134
148
|
PREVIEW_TICKET_TTL_MS,
|
|
135
149
|
PREVIEW_COOKIE_TTL_MS,
|
|
150
|
+
PREVIEW_SESSION_TTL_MS,
|
|
136
151
|
} from "./preview-session";
|
|
137
152
|
export { signPayload, verifyPayload } from "./signed-token";
|
|
138
153
|
export { getDocFromApi, listDocsFromApi } from "./api-loader";
|
|
@@ -147,5 +162,6 @@ export {
|
|
|
147
162
|
contentCacheTags,
|
|
148
163
|
listCacheTags,
|
|
149
164
|
pathsForCommittedDocs,
|
|
165
|
+
revalidateTagsForCommit,
|
|
150
166
|
revalidateUrl,
|
|
151
167
|
} from "./content-committed";
|
package/src/model.ts
CHANGED
|
@@ -33,12 +33,51 @@ export type ModelDef = {
|
|
|
33
33
|
* in a schema may set this.
|
|
34
34
|
*/
|
|
35
35
|
indexSlug?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Slugs belong to an outside system (a catalog). The host provides
|
|
38
|
+
* `sources/{type}.ts`; a record that does not exist there is a 404. A
|
|
39
|
+
* document at the `template` slug renders every record without its own file.
|
|
40
|
+
*/
|
|
41
|
+
external?: boolean;
|
|
42
|
+
/** Reserved slug of the template document. Default `_template` when `external`. */
|
|
43
|
+
template?: string;
|
|
36
44
|
fields: Record<string, FieldDef>;
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
/** What `defineModel` accepts: `path` falls back to the folder convention. */
|
|
40
48
|
export type ModelInput = Omit<ModelDef, "path"> & { path?: string };
|
|
41
49
|
|
|
50
|
+
export const DEFAULT_TEMPLATE_SLUG = "_template";
|
|
51
|
+
|
|
52
|
+
/** True when `slug` is the template document of an external model. */
|
|
53
|
+
export function isTemplateSlug(
|
|
54
|
+
model: Pick<ModelDef, "type" | "external" | "template"> | null | undefined,
|
|
55
|
+
slug: string,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (!model?.external) return false;
|
|
58
|
+
return slug === (model.template ?? DEFAULT_TEMPLATE_SLUG);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Validate `external` / `template` and return the template slug to store, if any. */
|
|
62
|
+
export function resolveTemplateSlug(
|
|
63
|
+
def: { external?: boolean; template?: string; singleton?: boolean; indexSlug?: string },
|
|
64
|
+
label = "model",
|
|
65
|
+
): string | undefined {
|
|
66
|
+
if (def.template !== undefined && !def.external) {
|
|
67
|
+
throw new Error(`${label}.template requires external: true`);
|
|
68
|
+
}
|
|
69
|
+
if (!def.external) return undefined;
|
|
70
|
+
if (def.singleton) throw new Error(`${label}.external cannot be combined with singleton`);
|
|
71
|
+
if (def.indexSlug !== undefined) {
|
|
72
|
+
throw new Error(`${label}.external cannot be combined with indexSlug`);
|
|
73
|
+
}
|
|
74
|
+
if (def.template === undefined) return DEFAULT_TEMPLATE_SLUG;
|
|
75
|
+
if (typeof def.template !== "string" || !def.template.trim()) {
|
|
76
|
+
throw new Error(`${label}.template must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
return def.template.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
42
81
|
/** Default repo path when a model does not declare one. */
|
|
43
82
|
export const DEFAULT_MODEL_PATH = "content/{locale}/{type}/{slug}.mdx";
|
|
44
83
|
export const DEFAULT_PAGE_PATH = "content/{locale}/{type}/{slug}.json";
|
|
@@ -55,6 +94,8 @@ export type ModelSnapshot = {
|
|
|
55
94
|
singleton?: boolean;
|
|
56
95
|
format?: DocFormat;
|
|
57
96
|
indexSlug?: string;
|
|
97
|
+
external?: boolean;
|
|
98
|
+
template?: string;
|
|
58
99
|
fields: Record<string, FieldSnapshot>;
|
|
59
100
|
};
|
|
60
101
|
|
|
@@ -91,12 +132,17 @@ export type SchemaSnapshot = {
|
|
|
91
132
|
};
|
|
92
133
|
|
|
93
134
|
type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
|
|
135
|
+
type WithTemplate<D extends ModelInput> = D extends { external: true }
|
|
136
|
+
? D extends { template: string }
|
|
137
|
+
? D
|
|
138
|
+
: D & { template: string }
|
|
139
|
+
: D;
|
|
94
140
|
|
|
95
141
|
/**
|
|
96
142
|
* Validates the definition at runtime and preserves its literal types.
|
|
97
143
|
* `path` defaults to `content/{locale}/{type}/{slug}.mdx`.
|
|
98
144
|
*/
|
|
99
|
-
export function defineModel<const D extends ModelInput>(def: D): WithPath<D
|
|
145
|
+
export function defineModel<const D extends ModelInput>(def: D): WithTemplate<WithPath<D>> {
|
|
100
146
|
if (!def.type || typeof def.type !== "string") {
|
|
101
147
|
throw new Error("model.type is required");
|
|
102
148
|
}
|
|
@@ -123,7 +169,11 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
|
|
|
123
169
|
if (!def.fields || typeof def.fields !== "object") {
|
|
124
170
|
throw new Error("model.fields is required");
|
|
125
171
|
}
|
|
126
|
-
const
|
|
172
|
+
const template = resolveTemplateSlug(def);
|
|
173
|
+
const withPath = (def.path === undefined ? { ...def, path } : { ...def }) as WithTemplate<
|
|
174
|
+
WithPath<D>
|
|
175
|
+
>;
|
|
176
|
+
if (template !== undefined) (withPath as { template?: string }).template = template;
|
|
127
177
|
if (singleton && def.route === undefined) {
|
|
128
178
|
return { ...withPath, route: false };
|
|
129
179
|
}
|
|
@@ -173,6 +223,10 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
|
|
|
173
223
|
if (model.singleton) snapshot.singleton = true;
|
|
174
224
|
if (model.format === "page") snapshot.format = "page";
|
|
175
225
|
if (model.indexSlug !== undefined) snapshot.indexSlug = model.indexSlug.trim();
|
|
226
|
+
if (model.external) {
|
|
227
|
+
snapshot.external = true;
|
|
228
|
+
snapshot.template = model.template ?? DEFAULT_TEMPLATE_SLUG;
|
|
229
|
+
}
|
|
176
230
|
return snapshot;
|
|
177
231
|
}
|
|
178
232
|
|
|
@@ -264,6 +318,22 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
|
|
|
264
318
|
}
|
|
265
319
|
snapshot.indexSlug = m.indexSlug.trim();
|
|
266
320
|
}
|
|
321
|
+
if (m.external !== undefined && typeof m.external !== "boolean") {
|
|
322
|
+
throw new Error(`schema.models[${i}].external must be a boolean`);
|
|
323
|
+
}
|
|
324
|
+
const template = resolveTemplateSlug(
|
|
325
|
+
{
|
|
326
|
+
external: m.external === true,
|
|
327
|
+
template: m.template as string | undefined,
|
|
328
|
+
singleton,
|
|
329
|
+
indexSlug: snapshot.indexSlug,
|
|
330
|
+
},
|
|
331
|
+
`schema.models[${i}]`,
|
|
332
|
+
);
|
|
333
|
+
if (template !== undefined) {
|
|
334
|
+
snapshot.external = true;
|
|
335
|
+
snapshot.template = template;
|
|
336
|
+
}
|
|
267
337
|
return snapshot;
|
|
268
338
|
});
|
|
269
339
|
|
package/src/node/cli.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type CliArgs = {
|
|
|
30
30
|
|
|
31
31
|
const USAGE = `Usage:
|
|
32
32
|
backmatter init scaffold backmatter.config.ts, the catch-all route and .backmatter/
|
|
33
|
-
backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts and
|
|
33
|
+
backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts, layouts.ts and sources.ts
|
|
34
34
|
backmatter validate [--root <dir>] [--models <file>] [--json]
|
|
35
35
|
backmatter schema [--root <dir>] [--models <file>] [--out <dir>]
|
|
36
36
|
|
|
@@ -239,6 +239,7 @@ export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
|
|
|
239
239
|
mdxDir: config.dirs.mdx,
|
|
240
240
|
layoutsDir: config.dirs.layouts,
|
|
241
241
|
objectsDir: config.dirs.objects,
|
|
242
|
+
sourcesDir: config.dirs.sources,
|
|
242
243
|
});
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -272,9 +273,10 @@ import { richtextComponents } from "${REGISTRY_SPECIFIER}/richtext";
|
|
|
272
273
|
import { blockLoaders } from "${REGISTRY_SPECIFIER}/blocks";
|
|
273
274
|
import { blockFallbacks } from "${REGISTRY_SPECIFIER}/block-fallbacks";
|
|
274
275
|
import { layouts } from "${REGISTRY_SPECIFIER}/layouts";
|
|
276
|
+
import { sources } from "${REGISTRY_SPECIFIER}/sources";
|
|
275
277
|
|
|
276
278
|
const components = { ...richtextComponents, ...mdxComponents };
|
|
277
|
-
const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
|
|
279
|
+
const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts, sources });
|
|
278
280
|
|
|
279
281
|
export const generateStaticParams = route.generateStaticParams;
|
|
280
282
|
|
|
@@ -348,6 +350,15 @@ export const config = {
|
|
|
348
350
|
`;
|
|
349
351
|
}
|
|
350
352
|
|
|
353
|
+
function renderCmsProxyRoute(): string {
|
|
354
|
+
return `// api/backmatter/cms/[...path]/route.ts — same-origin proxy for the preview session.
|
|
355
|
+
import { createCmsProxy } from "@backmatter/next/cms-proxy";
|
|
356
|
+
import { cms } from "${REGISTRY_SPECIFIER}";
|
|
357
|
+
|
|
358
|
+
export const { GET, POST, PUT, DELETE } = createCmsProxy(cms);
|
|
359
|
+
`;
|
|
360
|
+
}
|
|
361
|
+
|
|
351
362
|
function renderRevalidateRoute(): string {
|
|
352
363
|
return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
|
|
353
364
|
import { createRevalidateRoute } from "@backmatter/next/revalidate";
|
|
@@ -402,6 +413,16 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
402
413
|
notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
|
|
403
414
|
}
|
|
404
415
|
|
|
416
|
+
const cmsDir = path.join(root, appDir, "api", "backmatter", "cms", "[...path]");
|
|
417
|
+
const cmsFile = path.join(cmsDir, "route.ts");
|
|
418
|
+
if (!existsSync(cmsFile)) {
|
|
419
|
+
await mkdir(cmsDir, { recursive: true });
|
|
420
|
+
await writeFile(cmsFile, renderCmsProxyRoute());
|
|
421
|
+
notes.push(`wrote ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
|
|
422
|
+
} else {
|
|
423
|
+
notes.push(`kept existing ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
405
426
|
const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
|
|
406
427
|
const previewFile = path.join(previewDir, "route.ts");
|
|
407
428
|
if (!existsSync(previewFile)) {
|
|
@@ -450,7 +471,7 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
450
471
|
notes.push("no @backmatter/next dependency: skipped the catch-all route");
|
|
451
472
|
}
|
|
452
473
|
|
|
453
|
-
for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts"]) {
|
|
474
|
+
for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts", "sources"]) {
|
|
454
475
|
await mkdir(path.join(root, dir), { recursive: true });
|
|
455
476
|
}
|
|
456
477
|
|
package/src/node/discover.ts
CHANGED
|
@@ -101,6 +101,11 @@ export function listLayoutFiles(root: string, layoutsDir = "layouts"): ModelFile
|
|
|
101
101
|
export function listObjectFiles(root: string, objectsDir = "objects"): ModelFile[] {
|
|
102
102
|
return listModelFiles(root, objectsDir);
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/** `sources/{type}.ts`: one `defineSource` default export per external model type. */
|
|
106
|
+
export function listSourceDefFiles(root: string, sourcesDir = "sources"): ModelFile[] {
|
|
107
|
+
return listModelFiles(root, sourcesDir);
|
|
108
|
+
}
|
|
104
109
|
export function listBlockFiles(
|
|
105
110
|
root: string,
|
|
106
111
|
blocksDir = "blocks",
|
package/src/node/eval-model.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// packages/core/src/node/eval-model.ts
|
|
2
|
-
// Sandbox-eval
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Sandbox-eval convention model and object files (GitHub import). The allowed
|
|
3
|
+
// surface is the same one `discoverModels` / `discoverObjects` run: `defineModel`,
|
|
4
|
+
// `defineObject`, and `field` from @backmatter/core or @backmatter/next, plus
|
|
5
|
+
// `import name from "../objects/<name>"`. Never import() the customer checkout.
|
|
5
6
|
import { runInNewContext } from "node:vm";
|
|
6
7
|
import { defineModel, type ModelDef } from "../model";
|
|
8
|
+
import { defineObject, type ObjectDef } from "../object";
|
|
7
9
|
import { field } from "../fields";
|
|
8
10
|
|
|
9
11
|
const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
10
12
|
const IGNORED = /(\.test\.|\.spec\.|\.d\.ts$|\.stories\.)/;
|
|
11
13
|
const ALLOWED_IMPORT = /^\s*import\s+\{([^}]+)\}\s+from\s+["'](@backmatter\/core|@backmatter\/next)["']\s*;?\s*$/gm;
|
|
12
14
|
const ALLOWED_NAMES = new Set(["defineModel", "field"]);
|
|
15
|
+
const OBJECT_NAMES = new Set(["defineObject", "field"]);
|
|
13
16
|
const FORBIDDEN = [
|
|
14
17
|
{ re: /\brequire\s*\(/, name: "require" },
|
|
15
18
|
{ re: /\bimport\s*\(/, name: "import" },
|
|
@@ -44,38 +47,61 @@ function stemOf(repoPath: string): string {
|
|
|
44
47
|
return name.replace(SOURCE_EXT, "");
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
function stripAllowedImports(
|
|
50
|
+
function stripAllowedImports(
|
|
51
|
+
source: string,
|
|
52
|
+
allowed: Set<string> = ALLOWED_NAMES,
|
|
53
|
+
label = "defineModel and field",
|
|
54
|
+
): string {
|
|
55
|
+
ALLOWED_IMPORT.lastIndex = 0;
|
|
48
56
|
const names = new Set<string>();
|
|
49
57
|
const stripped = source.replace(ALLOWED_IMPORT, (_full, specifiers: string) => {
|
|
50
58
|
for (const part of specifiers.split(",")) {
|
|
51
59
|
const name = part.trim();
|
|
52
60
|
if (!name) continue;
|
|
53
|
-
if (/\bas\b/.test(name) || !
|
|
54
|
-
throw new Error(`import "${name}" is not allowed; only
|
|
61
|
+
if (/\bas\b/.test(name) || !allowed.has(name)) {
|
|
62
|
+
throw new Error(`import "${name}" is not allowed; only ${label}`);
|
|
55
63
|
}
|
|
56
64
|
names.add(name);
|
|
57
65
|
}
|
|
58
66
|
return "";
|
|
59
67
|
});
|
|
60
68
|
if (/^\s*import\b/m.test(stripped)) {
|
|
61
|
-
throw new Error(
|
|
69
|
+
throw new Error(
|
|
70
|
+
`import is not allowed except ${label} from @backmatter/core or @backmatter/next`,
|
|
71
|
+
);
|
|
62
72
|
}
|
|
63
73
|
return stripped;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
/** `import name from "../objects/<file>"` — the only local import a model may use. */
|
|
77
|
+
function takeObjectImports(source: string): {
|
|
78
|
+
source: string;
|
|
79
|
+
bindings: Array<{ local: string; stem: string }>;
|
|
80
|
+
} {
|
|
81
|
+
const bindings: Array<{ local: string; stem: string }> = [];
|
|
82
|
+
const re =
|
|
83
|
+
/^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+["']\.\.\/objects\/([A-Za-z][A-Za-z0-9]*)(?:\.(?:ts|tsx|js|jsx|mjs))?["']\s*;?\s*$/gm;
|
|
84
|
+
const next = source.replace(re, (_full, local: string, stem: string) => {
|
|
85
|
+
bindings.push({ local, stem });
|
|
86
|
+
return "";
|
|
87
|
+
});
|
|
88
|
+
return { source: next, bindings };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function assertNoForbidden(source: string, kind = "model"): void {
|
|
67
92
|
for (const { re, name } of FORBIDDEN) {
|
|
68
|
-
|
|
93
|
+
re.lastIndex = 0;
|
|
94
|
+
if (re.test(source)) throw new Error(`${name} is not allowed in ${kind} files`);
|
|
69
95
|
}
|
|
70
96
|
}
|
|
71
97
|
|
|
72
|
-
function rewriteDefaultExport(source: string): string {
|
|
98
|
+
function rewriteDefaultExport(source: string, what = "defineModel({ ... })"): string {
|
|
73
99
|
if (!/\bexport\s+default\b/.test(source)) {
|
|
74
|
-
throw new Error(
|
|
100
|
+
throw new Error(`must export default ${what}`);
|
|
75
101
|
}
|
|
76
102
|
const rewritten = source.replace(/\bexport\s+default\s+/, "return ");
|
|
77
103
|
if (/\bexport\b/.test(rewritten)) {
|
|
78
|
-
throw new Error(
|
|
104
|
+
throw new Error(`must export default ${what}`);
|
|
79
105
|
}
|
|
80
106
|
return rewritten;
|
|
81
107
|
}
|
|
@@ -85,18 +111,75 @@ function transpile(source: string): string {
|
|
|
85
111
|
return transpiler.transformSync(source);
|
|
86
112
|
}
|
|
87
113
|
|
|
114
|
+
function isObjectDef(value: unknown): value is ObjectDef {
|
|
115
|
+
if (!value || typeof value !== "object") return false;
|
|
116
|
+
const object = value as Record<string, unknown>;
|
|
117
|
+
return typeof object.name === "string" && typeof object.fields === "object" && !("type" in object);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Evaluate one `objects/{name}.ts` blob. Same rule as `discoverObjects`:
|
|
122
|
+
* the file stem must equal `object.name`.
|
|
123
|
+
*/
|
|
124
|
+
export function evalObjectSource(source: string, stem: string): ObjectDef {
|
|
125
|
+
const body = rewriteDefaultExport(
|
|
126
|
+
stripAllowedImports(source, OBJECT_NAMES, "defineObject and field"),
|
|
127
|
+
"defineObject({ ... })",
|
|
128
|
+
);
|
|
129
|
+
assertNoForbidden(body, "object");
|
|
130
|
+
const wrapped = `(function () {\n${body}\n})()`;
|
|
131
|
+
const js = transpile(wrapped);
|
|
132
|
+
const sandbox = Object.create(null) as {
|
|
133
|
+
defineObject: typeof defineObject;
|
|
134
|
+
field: typeof field;
|
|
135
|
+
};
|
|
136
|
+
sandbox.defineObject = defineObject;
|
|
137
|
+
sandbox.field = field;
|
|
138
|
+
Object.freeze(sandbox);
|
|
139
|
+
|
|
140
|
+
let result: unknown;
|
|
141
|
+
try {
|
|
142
|
+
result = runInNewContext(js, sandbox, {
|
|
143
|
+
timeout: EVAL_TIMEOUT_MS,
|
|
144
|
+
filename: `${stem}.js`,
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
148
|
+
throw new Error(`object "${stem}" failed to evaluate: ${message}`);
|
|
149
|
+
}
|
|
150
|
+
if (!isObjectDef(result)) {
|
|
151
|
+
throw new Error("must export default defineObject({ ... })");
|
|
152
|
+
}
|
|
153
|
+
if (result.name !== stem) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`defines name "${result.name}" but the file is named "${stem}"; rename one so they match`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
88
161
|
/**
|
|
89
162
|
* Evaluate one convention model file. `stem` is the file name without extension
|
|
90
|
-
* and must equal `model.type`.
|
|
163
|
+
* and must equal `model.type`. `objects` supplies `import x from "../objects/<file>"`.
|
|
91
164
|
*/
|
|
92
|
-
export function evalModelSource(
|
|
93
|
-
|
|
165
|
+
export function evalModelSource(
|
|
166
|
+
source: string,
|
|
167
|
+
stem: string,
|
|
168
|
+
objects: Readonly<Record<string, ObjectDef>> = {},
|
|
169
|
+
): ModelDef {
|
|
170
|
+
const taken = takeObjectImports(source);
|
|
171
|
+
const body = rewriteDefaultExport(stripAllowedImports(taken.source));
|
|
94
172
|
assertNoForbidden(body);
|
|
95
173
|
const wrapped = `(function () {\n${body}\n})()`;
|
|
96
174
|
const js = transpile(wrapped);
|
|
97
|
-
const sandbox = Object.create(null) as
|
|
175
|
+
const sandbox = Object.create(null) as Record<string, unknown>;
|
|
98
176
|
sandbox.defineModel = defineModel;
|
|
99
177
|
sandbox.field = field;
|
|
178
|
+
for (const binding of taken.bindings) {
|
|
179
|
+
const object = objects[binding.stem];
|
|
180
|
+
if (!object) throw new Error(`import "../objects/${binding.stem}" was not found`);
|
|
181
|
+
sandbox[binding.local] = object;
|
|
182
|
+
}
|
|
100
183
|
Object.freeze(sandbox);
|
|
101
184
|
|
|
102
185
|
let result: unknown;
|
|
@@ -125,6 +208,11 @@ export function evalModelsFromTree(
|
|
|
125
208
|
files: Array<{ path: string; source: string }>,
|
|
126
209
|
modelsDir = "models",
|
|
127
210
|
): ModelDef[] {
|
|
211
|
+
const objects: Record<string, ObjectDef> = {};
|
|
212
|
+
for (const file of files.filter((f) => isModelSourcePath(f.path, "objects"))) {
|
|
213
|
+
const stem = stemOf(file.path);
|
|
214
|
+
objects[stem] = evalObjectSource(file.source, stem);
|
|
215
|
+
}
|
|
128
216
|
const models: ModelDef[] = [];
|
|
129
217
|
const types = new Set<string>();
|
|
130
218
|
const sorted = files
|
|
@@ -132,7 +220,7 @@ export function evalModelsFromTree(
|
|
|
132
220
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
133
221
|
for (const file of sorted) {
|
|
134
222
|
const stem = stemOf(file.path);
|
|
135
|
-
const model = evalModelSource(file.source, stem);
|
|
223
|
+
const model = evalModelSource(file.source, stem, objects);
|
|
136
224
|
if (types.has(model.type)) {
|
|
137
225
|
throw new Error(`duplicate model type "${model.type}" (${file.path})`);
|
|
138
226
|
}
|