@milaboratories/pl-model-common 1.47.3 → 1.48.0

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.
Files changed (90) hide show
  1. package/dist/bmodel/block_kind_ref.cjs +45 -0
  2. package/dist/bmodel/block_kind_ref.cjs.map +1 -0
  3. package/dist/bmodel/block_kind_ref.d.ts +57 -0
  4. package/dist/bmodel/block_kind_ref.d.ts.map +1 -0
  5. package/dist/bmodel/block_kind_ref.js +43 -0
  6. package/dist/bmodel/block_kind_ref.js.map +1 -0
  7. package/dist/bmodel/container.d.ts +8 -0
  8. package/dist/bmodel/container.d.ts.map +1 -1
  9. package/dist/bmodel/index.cjs +4 -0
  10. package/dist/bmodel/index.d.ts +2 -1
  11. package/dist/bmodel/index.js +2 -1
  12. package/dist/columns/dedup.cjs +1 -1
  13. package/dist/columns/dedup.cjs.map +1 -1
  14. package/dist/columns/dedup.d.ts +1 -1
  15. package/dist/columns/dedup.js +1 -1
  16. package/dist/columns/dedup.js.map +1 -1
  17. package/dist/columns/providers.cjs +1 -1
  18. package/dist/columns/providers.cjs.map +1 -1
  19. package/dist/columns/providers.d.ts +1 -1
  20. package/dist/columns/providers.js +1 -1
  21. package/dist/columns/providers.js.map +1 -1
  22. package/dist/drivers/index.cjs +4 -0
  23. package/dist/drivers/index.d.ts +2 -2
  24. package/dist/drivers/index.js +2 -2
  25. package/dist/drivers/pframe/index.cjs +4 -0
  26. package/dist/drivers/pframe/index.d.ts +2 -2
  27. package/dist/drivers/pframe/index.js +2 -2
  28. package/dist/drivers/pframe/spec/ids.cjs +151 -0
  29. package/dist/drivers/pframe/spec/ids.cjs.map +1 -1
  30. package/dist/drivers/pframe/spec/ids.d.ts +53 -1
  31. package/dist/drivers/pframe/spec/ids.d.ts.map +1 -1
  32. package/dist/drivers/pframe/spec/ids.js +150 -3
  33. package/dist/drivers/pframe/spec/ids.js.map +1 -1
  34. package/dist/drivers/pframe/spec/index.cjs +4 -0
  35. package/dist/drivers/pframe/spec/index.d.ts +2 -2
  36. package/dist/drivers/pframe/spec/index.js +2 -2
  37. package/dist/index.cjs +29 -0
  38. package/dist/index.d.ts +7 -2
  39. package/dist/index.js +8 -2
  40. package/dist/plid.cjs +1 -1
  41. package/dist/plid.cjs.map +1 -1
  42. package/dist/plid.d.ts +3 -2
  43. package/dist/plid.d.ts.map +1 -1
  44. package/dist/plid.js +1 -1
  45. package/dist/plid.js.map +1 -1
  46. package/dist/template/index.cjs +20 -0
  47. package/dist/template/index.d.ts +5 -0
  48. package/dist/template/index.js +5 -0
  49. package/dist/template/kind_selector.cjs +92 -0
  50. package/dist/template/kind_selector.cjs.map +1 -0
  51. package/dist/template/kind_selector.d.ts +80 -0
  52. package/dist/template/kind_selector.d.ts.map +1 -0
  53. package/dist/template/kind_selector.js +87 -0
  54. package/dist/template/kind_selector.js.map +1 -0
  55. package/dist/template/project_template_v1.cjs +231 -0
  56. package/dist/template/project_template_v1.cjs.map +1 -0
  57. package/dist/template/project_template_v1.d.ts +215 -0
  58. package/dist/template/project_template_v1.d.ts.map +1 -0
  59. package/dist/template/project_template_v1.js +225 -0
  60. package/dist/template/project_template_v1.js.map +1 -0
  61. package/dist/template/template_ref_form.cjs +73 -0
  62. package/dist/template/template_ref_form.cjs.map +1 -0
  63. package/dist/template/template_ref_form.d.ts +74 -0
  64. package/dist/template/template_ref_form.d.ts.map +1 -0
  65. package/dist/template/template_ref_form.js +72 -0
  66. package/dist/template/template_ref_form.js.map +1 -0
  67. package/dist/template/template_relocate.cjs +46 -0
  68. package/dist/template/template_relocate.cjs.map +1 -0
  69. package/dist/template/template_relocate.d.ts +32 -0
  70. package/dist/template/template_relocate.d.ts.map +1 -0
  71. package/dist/template/template_relocate.js +46 -0
  72. package/dist/template/template_relocate.js.map +1 -0
  73. package/package.json +5 -5
  74. package/src/bmodel/block_kind_ref.ts +59 -0
  75. package/src/bmodel/container.ts +9 -0
  76. package/src/bmodel/index.ts +1 -0
  77. package/src/columns/dedup.ts +1 -1
  78. package/src/columns/providers.ts +1 -1
  79. package/src/drivers/pframe/spec/ids.test.ts +90 -0
  80. package/src/drivers/pframe/spec/ids.ts +191 -1
  81. package/src/index.ts +1 -0
  82. package/src/plid.ts +5 -5
  83. package/src/template/index.ts +4 -0
  84. package/src/template/kind_selector.ts +126 -0
  85. package/src/template/project_template_v1.test.ts +315 -0
  86. package/src/template/project_template_v1.ts +444 -0
  87. package/src/template/template_ref_form.test.ts +86 -0
  88. package/src/template/template_ref_form.ts +108 -0
  89. package/src/template/template_relocate.test.ts +182 -0
  90. package/src/template/template_relocate.ts +61 -0
@@ -0,0 +1,444 @@
1
+ import type { Branded } from "@milaboratories/helpers";
2
+ import { splitVersionedName } from "../bmodel/block_kind_ref";
3
+ import type { BlockKindSelectorReference } from "./kind_selector";
4
+ import { parseKindSelector, parseKindSelectorReference } from "./kind_selector";
5
+
6
+ /**
7
+ * Value of a template file's `schema` field — the format marker every
8
+ * `template-v1` document opens with.
9
+ */
10
+ export const PROJECT_TEMPLATE_SCHEMA_V1 = "template-v1";
11
+ export type ProjectTemplateSchemaV1 = typeof PROJECT_TEMPLATE_SCHEMA_V1;
12
+
13
+ /**
14
+ * On-wire reference to one exact block package version, `{name}@X.Y.Z`.
15
+ *
16
+ * The `block` override's value type. Exact only — the override exists to pin an
17
+ * implementation, so a range would defeat it. Mapping this to the structured
18
+ * `BlockPackId` (`{ organization, name, version }`) is import-side work; the
19
+ * organization lives inside the npm scope here, as it does for kind names.
20
+ */
21
+ export type BlockPackReference = Branded<string, "BlockPackReference">;
22
+
23
+ /**
24
+ * Split a {@link BlockPackReference} into `{ name, version }`.
25
+ *
26
+ * @throws if the reference carries no version segment or the version is not
27
+ * exactly `X.Y.Z`
28
+ */
29
+ export function parseBlockPackReference(ref: BlockPackReference): {
30
+ name: string;
31
+ version: string;
32
+ } {
33
+ const { name, version } = splitVersionedName(ref, "block package reference", "{name}@X.Y.Z");
34
+ const selector = parseKindSelector(version);
35
+ if (selector.op !== "exact") {
36
+ throw new Error(
37
+ `A 'block' override must pin an exact version (expected '{name}@X.Y.Z'): ${ref}`,
38
+ );
39
+ }
40
+ return { name, version: selector.version };
41
+ }
42
+
43
+ /**
44
+ * On-wire locator naming WHERE one entry's block implementation comes from, as an
45
+ * absolute URI: `file:///abs/path/to/block`.
46
+ *
47
+ * The third way an entry can reach an implementation, and the only one that names a
48
+ * place rather than a name. It exists for a block that is built but not published —
49
+ * the implementation lives in a folder and no registry knows it, so neither `kind`
50
+ * resolution nor a `block` version pin can find it.
51
+ *
52
+ * A URI rather than a bare path because the question "where" is not limited to the
53
+ * filesystem, and because scheme dispatch is how the rest of the toolchain already
54
+ * answers it. Which schemes an environment can actually serve is that environment's
55
+ * business: this type fixes only the grammar, so a document remains readable by a
56
+ * consumer that cannot fetch every scheme.
57
+ */
58
+ export type BlockPackLocationReference = Branded<string, "BlockPackLocationReference">;
59
+
60
+ /**
61
+ * Read the scheme off a {@link BlockPackLocationReference}, which is all the
62
+ * document layer knows about it — resolving the rest belongs to whoever can reach
63
+ * the scheme.
64
+ *
65
+ * A scheme is required. Accepting a bare path would mean reading it relative to
66
+ * whatever directory the application happens to have been started from, which is
67
+ * exactly the ambiguity a locator exists to remove.
68
+ *
69
+ * @throws if the value carries no scheme
70
+ */
71
+ export function parseBlockPackLocation(ref: BlockPackLocationReference): { scheme: string } {
72
+ const match = LocationSchemePattern.exec(ref);
73
+ if (!match) {
74
+ throw new Error(
75
+ `A 'location' must be an absolute URI with a scheme (expected e.g. ` +
76
+ `'file:///path/to/block'), got: ${ref}`,
77
+ );
78
+ }
79
+ return { scheme: match.groups!.scheme.toLowerCase() };
80
+ }
81
+
82
+ /**
83
+ * Scheme grammar, with one deliberate narrowing: a scheme is at least TWO
84
+ * characters, while the URI grammar allows one.
85
+ *
86
+ * `C:\blocks\my-block` is a valid single-letter-scheme URI, so a Windows path
87
+ * pasted into the field would otherwise be accepted with scheme `c` and then fail
88
+ * far away from the mistake. Rejecting it here means the error names the actual
89
+ * problem, and the fix — `file:///C:/blocks/my-block` — is spelled out.
90
+ */
91
+ const LocationSchemePattern = /^(?<scheme>[A-Za-z][A-Za-z0-9+.-]+):/;
92
+
93
+ /**
94
+ * One block in a template file.
95
+ *
96
+ * `kind` is always required: it carries the params contract the entry is typed against, and
97
+ * whichever of the three routes finds the block, what that block declares is checked against
98
+ * it — so params written for one contract cannot reach an implementation of another.
99
+ *
100
+ * A file may omit `params`, which is terseness and not an escape from the contract: the parser
101
+ * reads the omission as `{}`, so an entry that leaves it out still fails for a kind whose
102
+ * contract has required fields. Past the parser there is only one spelling, and no reader has
103
+ * to normalize. There is no `label` field: a template
104
+ * does not name block instances for display.
105
+ *
106
+ * An entry may also carry one locator override — see {@link BlockPackLocatorOverride} for
107
+ * what each answers. Either one is resolved on its own and kind resolution is skipped
108
+ * entirely; carrying both would state two different things with no way to reconcile them, so
109
+ * the type admits at most one.
110
+ */
111
+ export type ProjectTemplateV1Entry = {
112
+ /**
113
+ * Template-local identifier, unique within the file. Names the entry for
114
+ * inter-block references; on export it is the block's project-local UUID,
115
+ * reused verbatim.
116
+ */
117
+ readonly id: string;
118
+ readonly kind: BlockKindSelectorReference;
119
+ /**
120
+ * The block's `BlockParams` instance, exactly as the block projected it — opaque here
121
+ * and typed by the kind. Always present: an entry whose file omitted it parses as `{}`.
122
+ *
123
+ * **Nothing here looks inside.** Not for a reference, not for a marker: values travel from
124
+ * the block that projected them to the block that receives them verbatim, and which of them
125
+ * carry block ids is recognized in the receiving block's own bundle. A document layer that
126
+ * recognized a reference would have to model the whole reference system to do it.
127
+ *
128
+ * That is also what lets a hand-written file spell a reference readably. A block stores
129
+ * `{ __isRef: true, blockId, name }` and an export writes that, but a person may write
130
+ * `{ block: <entry id>, name: … }` instead — see `TemplatePlRef`. Both arrive at the block as
131
+ * the same reference; neither is understood here.
132
+ */
133
+ readonly params: Record<string, unknown>;
134
+ } & BlockPackLocatorOverride;
135
+
136
+ /**
137
+ * The locator override an entry may carry: a version pin, a place, or neither.
138
+ *
139
+ * Two arms rather than two optional fields, so "not both" is a property of the type and not
140
+ * only of the parser. Each arm forbids the other's field by typing it `never`, which is what
141
+ * makes `{ block, location }` match neither — and both arms leave their own field optional, so
142
+ * an entry that pins nothing satisfies either.
143
+ *
144
+ * Readers are unaffected: every arm declares both keys, so `entry.block` and `entry.location`
145
+ * stay directly readable without narrowing.
146
+ */
147
+ export type BlockPackLocatorOverride =
148
+ | {
149
+ /**
150
+ * WHICH VERSION to install, leaving it to the environment to decide which registry
151
+ * serves it — so an entry pinned this way stays portable.
152
+ *
153
+ * Exact only, `{name}@X.Y.Z` (see {@link BlockPackReference}): the override exists to
154
+ * pin one implementation, and a range would defeat that. Export never writes it, because
155
+ * it already records the exact version the block implements, leaving a pin nothing to
156
+ * add — so this is a hand-written field.
157
+ */
158
+ readonly block?: BlockPackReference;
159
+ /** Excluded: this arm is the version pin. */
160
+ readonly location?: never;
161
+ }
162
+ | {
163
+ /** Excluded: this arm is the place. */
164
+ readonly block?: never;
165
+ /**
166
+ * WHICH PLACE to install from, as an absolute URI (see
167
+ * {@link BlockPackLocationReference}).
168
+ *
169
+ * Names a concrete, possibly unpublished implementation, and is therefore only
170
+ * meaningful where that place exists: a `file:` locator written on one machine says
171
+ * nothing on another. That is the trade it makes — it is the only answer for a block
172
+ * that is built but not published, which no registry can find and no kind can resolve
173
+ * to. Export writes it for every block installed from the filesystem, since omitting it
174
+ * would describe a project that cannot be recreated at all.
175
+ */
176
+ readonly location?: BlockPackLocationReference;
177
+ };
178
+
179
+ /**
180
+ * A `template-v1` document — the primitive form of a template.
181
+ *
182
+ * `blocks` order is the instantiation order, so every entry must appear after
183
+ * the entries it references. This type is the shared contract for both
184
+ * directions of the round trip: export emits exactly this, import parses
185
+ * exactly this.
186
+ *
187
+ * Scope note: this package owns the *document*, i.e. the shape of the value a
188
+ * YAML (or JSON) reader hands back. The text layer stays out on purpose —
189
+ * pl-model-common is in every block-model and UI bundle and takes no `yaml`
190
+ * dependency; serializing to YAML bytes belongs with the caller that already
191
+ * has one (pl-middle-layer).
192
+ */
193
+ export type ProjectTemplateV1 = {
194
+ readonly schema: ProjectTemplateSchemaV1;
195
+ readonly blocks: readonly ProjectTemplateV1Entry[];
196
+ };
197
+
198
+ //
199
+ // Reading a decoded document.
200
+ //
201
+ // Hand-written rather than schema-driven, for what a reader of a hand-written file gets out
202
+ // of it. A template is a file a person edits, so the parser's output is a bug report: every
203
+ // problem at once, each located the way the file is written, worded as the edit to make. That
204
+ // means owning the wording, which a schema library gives away — and the values here need
205
+ // checks a schema cannot express anyway (the reference grammars are functions, and the locator
206
+ // exclusion is a rule about two fields), so the schema was carrying the trivial half while the
207
+ // interesting half sat in refinements beside it.
208
+ //
209
+
210
+ /** One thing wrong with a document, and where in it. */
211
+ export type TemplateParseIssue = {
212
+ /** Location in the decoded value: `["blocks", 2, "kind"]`. */
213
+ readonly path: readonly (string | number)[];
214
+ /** What is wrong, worded for whoever is editing the file. */
215
+ readonly message: string;
216
+ };
217
+
218
+ /**
219
+ * One issue as a line: `blocks[2].kind: Expected a kind reference, got nothing.`
220
+ *
221
+ * Indexes read as they are written in the file — `blocks[2]`, not `blocks.2` — so the place
222
+ * can be found by reading rather than by counting.
223
+ */
224
+ export function formatTemplateParseIssue(issue: TemplateParseIssue): string {
225
+ const path = issue.path.reduce<string>(
226
+ (acc, segment) =>
227
+ typeof segment === "number"
228
+ ? `${acc}[${segment}]`
229
+ : acc === ""
230
+ ? segment
231
+ : `${acc}.${segment}`,
232
+ "",
233
+ );
234
+ return path === "" ? issue.message : `${path}: ${issue.message}`;
235
+ }
236
+
237
+ /**
238
+ * Every problem a document has, thrown once so a caller fixes the file in one pass.
239
+ *
240
+ * The issues are in `message` as well as on `issues`, because a throw that escapes to a log is
241
+ * read as its message and nothing else.
242
+ */
243
+ export class ProjectTemplateV1ParseError extends Error {
244
+ constructor(readonly issues: readonly TemplateParseIssue[]) {
245
+ super(
246
+ [
247
+ `The template document could not be read (${issues.length} problem(s)):`,
248
+ ...issues.map((issue) => `- ${formatTemplateParseIssue(issue)}`),
249
+ ].join("\n"),
250
+ );
251
+ this.name = "ProjectTemplateV1ParseError";
252
+ }
253
+ }
254
+
255
+ /** A document, or everything wrong with the value that was supposed to be one. */
256
+ export type ProjectTemplateV1ReadResult =
257
+ | { readonly ok: true; readonly document: ProjectTemplateV1 }
258
+ | { readonly ok: false; readonly issues: readonly TemplateParseIssue[] };
259
+
260
+ const isMapping = (value: unknown): value is Record<string, unknown> =>
261
+ typeof value === "object" && value !== null && !Array.isArray(value);
262
+
263
+ /** Keys an entry may carry. Anything else is a mistake, not an extension point. */
264
+ const ENTRY_KEYS = ["id", "kind", "block", "location", "params"] as const;
265
+
266
+ /**
267
+ * Read an already-decoded template document — the value a YAML or JSON reader returns.
268
+ *
269
+ * Checks the format marker, every entry's shape, the reference grammars and id uniqueness,
270
+ * and settles an omitted `params` to `{}`. Unknown keys are refused rather than ignored: a
271
+ * misspelled key that was silently dropped would apply a file that does not say what its
272
+ * author meant.
273
+ *
274
+ * It does NOT check what an entry's params point at, and nothing downstream of it does
275
+ * either. Which values in there carry block ids is knowable only to the block, in its own
276
+ * bundle, where the params are relocated onto the project being built — so a reference to an
277
+ * entry listed later, or to the entry holding it, is not refused here. It survives into the
278
+ * applied project as a block whose references name nothing, which is how a reference to a
279
+ * deleted block already behaves.
280
+ *
281
+ * Collects rather than stops: a file with three mistakes should take one pass to fix.
282
+ */
283
+ export function readProjectTemplateV1(value: unknown): ProjectTemplateV1ReadResult {
284
+ const issues: TemplateParseIssue[] = [];
285
+ const fail = (path: readonly (string | number)[], message: string) =>
286
+ issues.push({ path, message });
287
+
288
+ if (!isMapping(value)) {
289
+ return {
290
+ ok: false,
291
+ issues: [{ path: [], message: "A template must be a mapping with 'schema' and 'blocks'." }],
292
+ };
293
+ }
294
+
295
+ for (const key of Object.keys(value)) {
296
+ if (key !== "schema" && key !== "blocks") fail([], `Unrecognized key: '${key}'`);
297
+ }
298
+
299
+ if (value.schema !== PROJECT_TEMPLATE_SCHEMA_V1) {
300
+ fail(
301
+ ["schema"],
302
+ `Expected '${PROJECT_TEMPLATE_SCHEMA_V1}', got ${describe(value.schema)}. This is the ` +
303
+ `format marker every template opens with.`,
304
+ );
305
+ }
306
+
307
+ if (!Array.isArray(value.blocks)) {
308
+ fail(["blocks"], `Expected a list of entries, got ${describe(value.blocks)}.`);
309
+ return { ok: false, issues };
310
+ }
311
+
312
+ const blocks: ProjectTemplateV1Entry[] = [];
313
+ const seen = new Set<string>();
314
+
315
+ value.blocks.forEach((raw, i) => {
316
+ const entry = readEntry(raw, ["blocks", i], fail);
317
+ if (entry === undefined) return;
318
+ if (seen.has(entry.id)) fail(["blocks", i, "id"], `Duplicate template-local id: ${entry.id}`);
319
+ seen.add(entry.id);
320
+ blocks.push(entry);
321
+ });
322
+
323
+ if (issues.length > 0) return { ok: false, issues };
324
+ return { ok: true, document: { schema: PROJECT_TEMPLATE_SCHEMA_V1, blocks } };
325
+ }
326
+
327
+ /**
328
+ * One entry, or `undefined` when it is not even a mapping — in which case its own fields are
329
+ * not reported on top, since a reader given "this entry is not a mapping" does not also need
330
+ * to hear that its `id` is missing.
331
+ */
332
+ function readEntry(
333
+ raw: unknown,
334
+ at: readonly (string | number)[],
335
+ fail: (path: readonly (string | number)[], message: string) => void,
336
+ ): ProjectTemplateV1Entry | undefined {
337
+ if (!isMapping(raw)) {
338
+ fail(at, `Expected an entry mapping, got ${describe(raw)}.`);
339
+ return undefined;
340
+ }
341
+
342
+ for (const key of Object.keys(raw)) {
343
+ if (!(ENTRY_KEYS as readonly string[]).includes(key)) {
344
+ fail(at, `Unrecognized key: '${key}'`);
345
+ }
346
+ }
347
+
348
+ let ok = true;
349
+
350
+ if (typeof raw.id !== "string" || raw.id.length === 0) {
351
+ fail([...at, "id"], `Expected a non-empty id, got ${describe(raw.id)}.`);
352
+ ok = false;
353
+ }
354
+
355
+ if (typeof raw.kind !== "string") {
356
+ fail([...at, "kind"], `Expected a kind reference, got ${describe(raw.kind)}.`);
357
+ ok = false;
358
+ } else {
359
+ // The grammars are functions, and their messages already name the fix, so they are
360
+ // reported as they come rather than restated.
361
+ ok = check([...at, "kind"], () => parseKindSelectorReference(raw.kind as never), fail) && ok;
362
+ }
363
+
364
+ if (raw.block !== undefined) {
365
+ if (typeof raw.block !== "string") {
366
+ fail([...at, "block"], `Expected a block package reference, got ${describe(raw.block)}.`);
367
+ ok = false;
368
+ } else {
369
+ ok = check([...at, "block"], () => parseBlockPackReference(raw.block as never), fail) && ok;
370
+ }
371
+ }
372
+
373
+ if (raw.location !== undefined) {
374
+ if (typeof raw.location !== "string") {
375
+ fail([...at, "location"], `Expected a locator URI, got ${describe(raw.location)}.`);
376
+ ok = false;
377
+ } else {
378
+ ok =
379
+ check([...at, "location"], () => parseBlockPackLocation(raw.location as never), fail) && ok;
380
+ }
381
+ }
382
+
383
+ if (raw.block !== undefined && raw.location !== undefined) {
384
+ fail(
385
+ [...at, "location"],
386
+ `An entry cannot carry both 'block' and 'location': the first pins which version to ` +
387
+ `install, the second pins where to install it from. Keep the one that is actually meant.`,
388
+ );
389
+ ok = false;
390
+ }
391
+
392
+ if (raw.params !== undefined && !isMapping(raw.params)) {
393
+ fail([...at, "params"], `Expected a mapping of params, got ${describe(raw.params)}.`);
394
+ ok = false;
395
+ }
396
+
397
+ if (!ok) return undefined;
398
+
399
+ return {
400
+ id: raw.id as string,
401
+ kind: raw.kind as BlockKindSelectorReference,
402
+ ...(raw.block !== undefined ? { block: raw.block as BlockPackReference } : {}),
403
+ ...(raw.location !== undefined ? { location: raw.location as BlockPackLocationReference } : {}),
404
+ // Omissible in the file, settled here: every reader downstream gets a mapping.
405
+ params: (raw.params ?? {}) as Record<string, unknown>,
406
+ } as ProjectTemplateV1Entry;
407
+ }
408
+
409
+ /** Run a grammar check, turning its throw into an issue at `path`. */
410
+ function check(
411
+ path: readonly (string | number)[],
412
+ grammar: () => unknown,
413
+ fail: (path: readonly (string | number)[], message: string) => void,
414
+ ): boolean {
415
+ try {
416
+ grammar();
417
+ return true;
418
+ } catch (e) {
419
+ fail(path, e instanceof Error ? e.message : String(e));
420
+ return false;
421
+ }
422
+ }
423
+
424
+ /** A value named the way an error message should name it, without printing its contents. */
425
+ function describe(value: unknown): string {
426
+ if (value === undefined) return "nothing";
427
+ if (value === null) return "null";
428
+ if (Array.isArray(value)) return "a list";
429
+ if (typeof value === "string") return `'${value}'`;
430
+ if (typeof value === "object") return "a mapping";
431
+ return String(value);
432
+ }
433
+
434
+ /**
435
+ * {@link readProjectTemplateV1} for a caller that treats an unreadable document as
436
+ * exceptional — export, which asserts on every run that what it wrote can be read back.
437
+ *
438
+ * @throws {ProjectTemplateV1ParseError} carrying every problem found
439
+ */
440
+ export function parseProjectTemplateV1(value: unknown): ProjectTemplateV1 {
441
+ const outcome = readProjectTemplateV1(value);
442
+ if (!outcome.ok) throw new ProjectTemplateV1ParseError(outcome.issues);
443
+ return outcome.document;
444
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createPlRef } from "../ref";
3
+ import { expandTemplateRefs, isTemplatePlRef } from "./template_ref_form";
4
+
5
+ /**
6
+ * The readable spelling of a reference, and what it becomes.
7
+ *
8
+ * `{ block, name }` exists so a person can write a reference without the `__isRef` marker. It
9
+ * is input only, and it carries exactly what a `PlRef` carries — so expanding it is a rewrite
10
+ * of spelling, needing nothing but the value itself.
11
+ */
12
+
13
+ describe("isTemplatePlRef", () => {
14
+ test("an entry id and an output name are the form", () => {
15
+ expect(isTemplatePlRef({ block: "samples", name: "reads" })).toBe(true);
16
+ });
17
+
18
+ test("a PlRef is not this form, and cannot be mistaken for it", () => {
19
+ // The two never overlap: one carries `__isRef` and a `blockId`, the other carries neither.
20
+ expect(isTemplatePlRef(createPlRef("samples", "reads"))).toBe(false);
21
+ });
22
+
23
+ test("anything else is left for the kind to own", () => {
24
+ // The shape sits inside params a kind declares, so it has to be narrow: an object with a
25
+ // third key is the kind's own value, not a reference someone spelled loosely.
26
+ expect(isTemplatePlRef({ block: "samples", name: "reads", extra: 1 })).toBe(false);
27
+ expect(isTemplatePlRef({ block: "samples" })).toBe(false);
28
+ expect(isTemplatePlRef({ name: "reads" })).toBe(false);
29
+ expect(isTemplatePlRef({ block: 0, name: "reads" })).toBe(false);
30
+ expect(isTemplatePlRef({ block: "samples", name: 1 })).toBe(false);
31
+ expect(isTemplatePlRef([{ block: "samples", name: "reads" }])).toBe(false);
32
+ expect(isTemplatePlRef(null)).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe("expandTemplateRefs", () => {
37
+ test("a form is expanded as a whole, never descended into", () => {
38
+ // The property the next readable form will rely on: recognition happens before the generic
39
+ // object walk, so `{ block, name }` is replaced rather than having its fields rewritten.
40
+ // A wrapper form that nests another reference needs exactly this, bottom-up.
41
+ const params = { input: { block: "samples", name: "reads" } };
42
+
43
+ expect(Object.keys(expandTemplateRefs(params).input)).toEqual(["__isRef", "blockId", "name"]);
44
+ });
45
+
46
+ test("becomes the PlRef it stands for, naming the same entry", () => {
47
+ expect(expandTemplateRefs({ input: { block: "samples", name: "reads" } })).toEqual({
48
+ input: createPlRef("samples", "reads"),
49
+ });
50
+ });
51
+
52
+ test("an id naming no entry passes through, like a hand-written PlRef would", () => {
53
+ // Not an error: an id naming nothing and an id naming an entry created later are the same
54
+ // thing here, and both are meant to reach a block that reports missing references.
55
+ expect(expandTemplateRefs({ input: { block: "ghost", name: "x" } })).toEqual({
56
+ input: createPlRef("ghost", "x"),
57
+ });
58
+ });
59
+
60
+ test("references are found in arrays and at depth", () => {
61
+ const params = {
62
+ sources: [
63
+ { block: "a", name: "numbers" },
64
+ { block: "b", name: "numbers" },
65
+ ],
66
+ nested: { deeper: { anchor: { block: "c", name: "table" } } },
67
+ };
68
+
69
+ expect(expandTemplateRefs(params)).toEqual({
70
+ sources: [createPlRef("a", "numbers"), createPlRef("b", "numbers")],
71
+ nested: { deeper: { anchor: createPlRef("c", "table") } },
72
+ });
73
+ });
74
+
75
+ test("a PlRef already in long form is untouched", () => {
76
+ const params = { input: createPlRef("samples", "reads") };
77
+
78
+ expect(expandTemplateRefs(params)).toEqual(params);
79
+ });
80
+
81
+ test("params with nothing to expand come back unchanged", () => {
82
+ const params = { numbers: [1, 2], label: "run", nothing: null, empty: {} };
83
+
84
+ expect(expandTemplateRefs(params)).toEqual(params);
85
+ });
86
+ });
@@ -0,0 +1,108 @@
1
+ import type { PlRef } from "../ref";
2
+
3
+ /**
4
+ * A reference as a person writes it: the entry it points at, and the output name.
5
+ *
6
+ * The readable spelling of a {@link PlRef}, and input only. The two say the same thing, and
7
+ * differ only in what a reader has to carry:
8
+ *
9
+ * ```yaml
10
+ * # what an export writes, and what a block holds
11
+ * sources:
12
+ * - __isRef: true
13
+ * blockId: samples
14
+ * name: numbers
15
+ *
16
+ * # the same reference, written by hand
17
+ * sources:
18
+ * - { block: samples, name: numbers }
19
+ * ```
20
+ *
21
+ * `block` is a template-local entry id — the same thing a `PlRef`'s `blockId` holds inside a
22
+ * template file. Nothing else: this form exists to drop the `__isRef` marker and the word
23
+ * `blockId`, not to introduce a second way of naming an entry.
24
+ *
25
+ * Export never emits it — a block holds live `PlRef`s and a template holds what the block
26
+ * holds. It is expanded on the way in, inside the block's own bundle and before the kind's
27
+ * parser runs, so a kind's params contract is written against `PlRef` alone and never learns
28
+ * this type exists.
29
+ */
30
+ export type TemplatePlRef = {
31
+ /** The template-local id of the entry this points at. */
32
+ readonly block: string;
33
+ /** The upstream output's name, exactly as a `PlRef` spells it. */
34
+ readonly name: string;
35
+ };
36
+
37
+ /**
38
+ * Whether `value` is a reference in the readable spelling.
39
+ *
40
+ * Exact about its keys, because the shape lives inside params a kind owns: `{ block, name }`
41
+ * and nothing else. A value carrying `__isRef` is a `PlRef` already and is not this — the two
42
+ * are told apart by shape and never overlap.
43
+ */
44
+ export function isTemplatePlRef(value: unknown): value is TemplatePlRef {
45
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
46
+ const keys = Object.keys(value);
47
+ if (keys.length !== 2 || !keys.includes("block") || !keys.includes("name")) return false;
48
+ const { block, name } = value as { block: unknown; name: unknown };
49
+ return typeof block === "string" && typeof name === "string";
50
+ }
51
+
52
+ /**
53
+ * Expand every readable reference form in `params` into the form the system stores.
54
+ *
55
+ * Named for the job and not for today's only case. One readable form exists so far —
56
+ * {@link TemplatePlRef}, the leaf reference — and the rest of the identifier system is meant to
57
+ * follow: `TemplateCUId` / `TemplateCUKey`, readable spellings of the filtered, discovered and
58
+ * overridden column keys, whose long forms are far worse to type than a `PlRef`'s.
59
+ *
60
+ * **Adding one is a recognizer plus an expander**, checked before the generic object case, the
61
+ * way `isTemplatePlRef` / `expandTemplatePlRef` are below. Two rules a nesting form has to
62
+ * respect, both consequences of how the stored forms are built:
63
+ *
64
+ * - **Expand bottom-up.** A wrapper key holds its source as a canonical *string*, not as an
65
+ * object, so the inner reference must be expanded and serialized before the outer key can be
66
+ * assembled. Descending after building the outer form would leave the inner spelling inside a
67
+ * string nothing looks at again.
68
+ * - **Canonicalize what you build.** An identifier IS its canonical string; a key assembled
69
+ * with keys in another order is a different identifier for the same column.
70
+ *
71
+ * What comes out names its upstreams by template-local entry id — the same thing a `PlRef` in a
72
+ * template file means. Turning those into the ids of real blocks is {@link relocateBlockIds},
73
+ * which runs right after and treats an expanded reference exactly like one the file spelled out
74
+ * in full.
75
+ *
76
+ * Needs nothing but the params. That is a property of the forms, not a coincidence: a readable
77
+ * spelling carries the same information as the form it stands for, so expansion is a rewrite and
78
+ * never a lookup. A form that needed the document to expand — a reference by position, say —
79
+ * would have to be resolved somewhere that knows the document, and would drag that knowledge
80
+ * into every caller of this. Keep them information-preserving.
81
+ */
82
+ export function expandTemplateRefs<T>(params: T): T {
83
+ const walk = (node: unknown): unknown => {
84
+ // One line per readable form, before the generic object case: a form IS an object, and
85
+ // descending into one would rewrite its parts instead of expanding it as a whole.
86
+ if (isTemplatePlRef(node)) return expandTemplatePlRef(node);
87
+
88
+ if (Array.isArray(node)) return node.map(walk);
89
+
90
+ if (typeof node === "object" && node !== null) {
91
+ return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, walk(v)]));
92
+ }
93
+
94
+ return node;
95
+ };
96
+ return walk(params) as T;
97
+ }
98
+
99
+ /**
100
+ * The leaf form's expander: `{ block, name }` becomes the `PlRef` it stands for.
101
+ *
102
+ * An id naming no entry is passed through, like a hand-written `PlRef` would be: an id naming
103
+ * nothing and an id naming an entry created later are indistinguishable, and both are meant to
104
+ * arrive at a block that reports itself as missing references.
105
+ */
106
+ function expandTemplatePlRef(ref: TemplatePlRef): PlRef {
107
+ return { __isRef: true, blockId: ref.block, name: ref.name };
108
+ }