@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/core",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
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
- /** TypeScript shape of a block's props, derived from `field.*` calls. */
62
- export type InferBlockProps<Fields extends Record<string, FieldDef>> = Simplify<
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
  }
@@ -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 withPath = (def.path === undefined ? { ...def, path } : { ...def }) as WithPath<D>;
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 layouts.ts
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
 
@@ -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",
@@ -1,15 +1,18 @@
1
1
  // packages/core/src/node/eval-model.ts
2
- // Sandbox-eval a convention `models/{type}.ts` blob (GitHub import). Only
3
- // `defineModel` / `field` from @backmatter/core or @backmatter/next are in scope —
4
- // never import() the customer checkout.
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(source: string): string {
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) || !ALLOWED_NAMES.has(name)) {
54
- throw new Error(`import "${name}" is not allowed; only defineModel and field`);
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("import is not allowed except defineModel/field from @backmatter/core or @backmatter/next");
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
- function assertNoForbidden(source: string): void {
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
- if (re.test(source)) throw new Error(`${name} is not allowed in model files`);
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("must export default defineModel({ ... })");
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("must export default defineModel({ ... })");
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(source: string, stem: string): ModelDef {
93
- const body = rewriteDefaultExport(stripAllowedImports(source));
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 { defineModel: typeof defineModel; field: typeof field };
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
  }