mechanica-shared 2.0.0-alpha.8 → 2.0.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/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # mechanica-shared
2
2
 
3
- Internal support package for [mechanica](https://www.npmjs.com/package/mechanica) — DOM-free types, the field-type registry, schema/default helpers, the page-generation (SSG) core, and the `.page.md` page-format codec.
3
+ Internal support package for [mechanica](https://www.npmjs.com/package/mechanica): DOM-free types, the field-type registry, schema/default helpers, the page-generation (SSG) core, SEO helpers, and the codecs for Mechanica's on-disk formats.
4
4
 
5
- You normally don't install this directly; it comes with `mechanica`. It is published separately so server-side renderers can consume it without the editor/runtime.
5
+ You normally don't install this directly; it comes with `mechanica`. It's published separately so server-side renderers can use it without the editor or the runtime. Keep its version in step with `mechanica`'s.
6
6
 
7
7
  Entry points:
8
8
 
9
- - `mechanica-shared` — types, field registry, schema helpers, `generatePage` and the `{{ }}` HTML templating engine. DOM-free by contract.
10
- - `mechanica-shared/page-format` — `parsePage` / `serializePage` for the `.page.md` on-disk format. A separate entry point on purpose: it pulls in a YAML parser that must never reach a client bundle.
9
+ - `mechanica-shared`: types, the field registry, schema helpers, `generatePage` and the `{{ }}` HTML templating engine, the query engine, locale and translation-overlay helpers, and the SEO helpers (`applySeoTags`, `auditPageHtml`, `buildSitemap`, `buildRobotsTxt`). DOM-free by contract.
10
+ - `mechanica-shared/page-format`: `parsePage` / `serializePage` for `.page.md` page files.
11
+ - `mechanica-shared/block-format`: `parseComposedBlock` / `serializeComposedBlock` for the Block Composer's `.block.yml` files.
12
+
13
+ The two codecs are separate entry points on purpose: they pull in a YAML parser that must never reach a client bundle.
@@ -65,6 +65,8 @@ function parseComposedBlock(text, fallbackId) {
65
65
  };
66
66
  if (typeof doc.icon === "string") def.icon = doc.icon;
67
67
  if (typeof doc.category === "string") def.category = doc.category;
68
+ if (doc.hidden === true) def.hidden = true;
69
+ if (doc.standalone === true) def.standalone = true;
68
70
  if (isPlainObject(doc.props)) def.props = doc.props;
69
71
  if (isPlainObject(doc.previewData)) def.previewData = doc.previewData;
70
72
  return def;
@@ -77,6 +79,8 @@ function serializeComposedBlock(def) {
77
79
  };
78
80
  if (def.icon) ordered.icon = def.icon;
79
81
  if (def.category) ordered.category = def.category;
82
+ if (def.hidden) ordered.hidden = true;
83
+ if (def.standalone) ordered.standalone = true;
80
84
  if (def.props && Object.keys(def.props).length) ordered.props = def.props;
81
85
  if (def.previewData && Object.keys(def.previewData).length) ordered.previewData = def.previewData;
82
86
  ordered.template = def.template ?? [];
package/dist/index.js CHANGED
@@ -122,18 +122,34 @@ function localeLabel(config, code) {
122
122
  */
123
123
  /** Reserved data keys the composer engine consumes (never passed as props). */
124
124
  var IF_KEY = "$if";
125
+ var EACH_KEY = "$each";
125
126
  /** Whether a value is a prop binding (`{ $bind: 'name' }`). */
126
127
  function isBinding(value) {
127
128
  return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.$bind === "string";
128
129
  }
129
130
  /**
131
+ * Look up a binding name in a prop scope. Supports dot paths (`$item.title`,
132
+ * `cta.url`) so a repeated subtree can bind fields of the current `$item` and a
133
+ * binding can reach into an object prop without exposing each leaf separately.
134
+ */
135
+ function lookupBinding(name, props) {
136
+ if (name in props) return props[name];
137
+ if (!name.includes(".")) return void 0;
138
+ let value = props;
139
+ for (const part of name.split(".")) {
140
+ if (value === null || typeof value !== "object") return void 0;
141
+ value = value[part];
142
+ }
143
+ return value;
144
+ }
145
+ /**
130
146
  * Deep-resolve a data value: substitute every `{ $bind: name }` with the named
131
147
  * prop, recursing through arrays and plain objects (so a bound value nested in
132
148
  * e.g. a `smartLink` object resolves too). Non-binding scalars pass through;
133
149
  * everything is cloned, so the returned value never aliases the template.
134
150
  */
135
151
  function resolveBindings(value, props) {
136
- if (isBinding(value)) return props[value.$bind];
152
+ if (isBinding(value)) return lookupBinding(value.$bind, props);
137
153
  if (Array.isArray(value)) return value.map((item) => resolveBindings(item, props));
138
154
  if (value !== null && typeof value === "object") {
139
155
  const out = {};
@@ -143,15 +159,15 @@ function resolveBindings(value, props) {
143
159
  return value;
144
160
  }
145
161
  /** Resolve one template node, or `null` when a falsy `$if` binding drops it. */
146
- function resolveNode(node, props, prefix, index) {
162
+ function resolveNode(node, props, prefix, index, idSuffix = "") {
147
163
  const raw = node.data ?? {};
148
164
  if (IF_KEY in raw && !resolveBindings(raw[IF_KEY], props)) return null;
149
165
  const data = {};
150
166
  for (const [key, value] of Object.entries(raw)) {
151
- if (key === IF_KEY) continue;
167
+ if (key === IF_KEY || key === EACH_KEY) continue;
152
168
  data[key] = resolveBindings(value, props);
153
169
  }
154
- const id = `${prefix}:${node.id ?? index}`;
170
+ const id = `${prefix}:${node.id ?? index}${idSuffix}`;
155
171
  const resolved = {
156
172
  id,
157
173
  blockId: node.blockId,
@@ -164,6 +180,20 @@ function resolveNode(node, props, prefix, index) {
164
180
  function resolveList(list, props, prefix) {
165
181
  const out = [];
166
182
  list.forEach((child, index) => {
183
+ const each = child.data?.[EACH_KEY];
184
+ if (typeof each === "string") {
185
+ const items = lookupBinding(each, props);
186
+ if (!Array.isArray(items)) return;
187
+ items.forEach((item, i) => {
188
+ const resolved = resolveNode(child, {
189
+ ...props,
190
+ $item: item,
191
+ $index: i
192
+ }, prefix, index, `@${i}`);
193
+ if (resolved) out.push(resolved);
194
+ });
195
+ return;
196
+ }
167
197
  const resolved = resolveNode(child, props, prefix, index);
168
198
  if (resolved) out.push(resolved);
169
199
  });
@@ -177,9 +207,10 @@ function resolveChildren(children, props, prefix) {
177
207
  }
178
208
  /**
179
209
  * Expand a composed block's template into a concrete content tree: substitute
180
- * `$bind` values from `props`, drop `$if`-hidden nodes, and namespace every
181
- * node id under `instanceId` (the placed block's id — stable across renders).
182
- * The result is rendered by the normal block-render pipeline.
210
+ * `$bind` values from `props`, repeat `$each` nodes per array item, drop
211
+ * `$if`-hidden nodes, and namespace every node id under `instanceId` (the
212
+ * placed block's id — stable across renders). The result is rendered by the
213
+ * normal block-render pipeline.
183
214
  */
184
215
  function resolveComposedTemplate(def, props, instanceId) {
185
216
  return resolveList(def.template ?? [], props ?? {}, instanceId);
@@ -479,7 +510,10 @@ async function generatePage(options) {
479
510
  index = index.slice(0, start) + rendered + index.slice(end);
480
511
  const links = options.pageLinks?.(options.state.content) ?? [];
481
512
  if (links.length) index = index.replace("</head>", `${links.join("\n")}\n</head>`);
482
- if (options.assetsUrl) index = index.replace(/\/assets\//g, options.assetsUrl);
513
+ if (options.assetsUrl) {
514
+ const base = options.assetsUrl.replace(/\/+$/, "");
515
+ index = index.replace(/(["'(=])\/assets\//g, (_, edge) => `${edge}${base}/assets/`);
516
+ }
483
517
  const stateScript = `<script>window.state=${serializeState(state)}<\/script>`;
484
518
  return {
485
519
  html: index.replace("</body>", `${stateScript}\n</body>`),
@@ -968,4 +1002,4 @@ async function resolveQueryKey(source, key, context = {}) {
968
1002
  return {};
969
1003
  }
970
1004
  //#endregion
971
- export { applySeoTags, areFieldSchemasRegistered, auditPageHtml, buildPreviewData, buildRobotsTxt, buildSitemap, builtinFields, collectInternalLinks, deepEqual, diffBlocks, diffTranslation, diffValue, findUnknownBlocks, generatePage, generateProject, getDefaultValue, getFieldDefault, getValueByPath, isBinding, isLocale, isPaginatedQuery, localeLabel, localePath, mergeBlocks, mergePreviewData, mergeTranslation, mergeValue, migrateContent, normalizeClassManifest, normalizeInternalUrl, normalizeLocales, pageUrl, paginationVariantPath, parseLocalePath, parseQueryKey, passDataToHTML, passDefaultValue, registerFieldSchemas, resolveBindings, resolveComposedTemplate, resolvePagesQuery, resolveQueryKey, serializeState, templateBlockIds, validateLinks, walkSchema, walkTree };
1005
+ export { applySeoTags, areFieldSchemasRegistered, auditPageHtml, buildPreviewData, buildRobotsTxt, buildSitemap, builtinFields, collectInternalLinks, deepEqual, diffBlocks, diffTranslation, diffValue, findUnknownBlocks, generatePage, generateProject, getDefaultValue, getFieldDefault, getValueByPath, isBinding, isLocale, isPaginatedQuery, localeLabel, localePath, lookupBinding, mergeBlocks, mergePreviewData, mergeTranslation, mergeValue, migrateContent, normalizeClassManifest, normalizeInternalUrl, normalizeLocales, pageUrl, paginationVariantPath, parseLocalePath, parseQueryKey, passDataToHTML, passDefaultValue, registerFieldSchemas, resolveBindings, resolveComposedTemplate, resolvePagesQuery, resolveQueryKey, serializeState, templateBlockIds, validateLinks, walkSchema, walkTree };
@@ -171,6 +171,7 @@ function parsePage(text, options) {
171
171
  return {
172
172
  ...typeof envelope.name === "string" ? { name: envelope.name } : {},
173
173
  ...envelope.draft === true ? { draft: true } : {},
174
+ ...typeof envelope.layout === "string" && envelope.layout !== "" ? { layout: envelope.layout } : {},
174
175
  ...envelope.meta !== void 0 ? { meta: envelope.meta } : {},
175
176
  ...typeof envelope.order === "number" ? { order: envelope.order } : {},
176
177
  ...envelope.orderAfter != null ? { orderAfter: envelope.orderAfter } : {},
@@ -215,6 +216,7 @@ function serializePage(doc, options) {
215
216
  const envelope = {};
216
217
  if (doc.name !== void 0) envelope.name = doc.name;
217
218
  if (doc.draft) envelope.draft = true;
219
+ if (doc.layout) envelope.layout = doc.layout;
218
220
  if (doc.meta !== void 0) envelope.meta = doc.meta;
219
221
  envelope.data = doc.data ?? {};
220
222
  if (doc.order !== void 0) envelope.order = doc.order;
@@ -1,6 +1,12 @@
1
1
  import type { ComposedBlockDefinition, ContentBlock, PropBinding } from './types';
2
2
  /** Whether a value is a prop binding (`{ $bind: 'name' }`). */
3
3
  export declare function isBinding(value: unknown): value is PropBinding;
4
+ /**
5
+ * Look up a binding name in a prop scope. Supports dot paths (`$item.title`,
6
+ * `cta.url`) so a repeated subtree can bind fields of the current `$item` and a
7
+ * binding can reach into an object prop without exposing each leaf separately.
8
+ */
9
+ export declare function lookupBinding(name: string, props: Record<string, unknown>): unknown;
4
10
  /**
5
11
  * Deep-resolve a data value: substitute every `{ $bind: name }` with the named
6
12
  * prop, recursing through arrays and plain objects (so a bound value nested in
@@ -10,9 +16,10 @@ export declare function isBinding(value: unknown): value is PropBinding;
10
16
  export declare function resolveBindings(value: unknown, props: Record<string, unknown>): unknown;
11
17
  /**
12
18
  * Expand a composed block's template into a concrete content tree: substitute
13
- * `$bind` values from `props`, drop `$if`-hidden nodes, and namespace every
14
- * node id under `instanceId` (the placed block's id — stable across renders).
15
- * The result is rendered by the normal block-render pipeline.
19
+ * `$bind` values from `props`, repeat `$each` nodes per array item, drop
20
+ * `$if`-hidden nodes, and namespace every node id under `instanceId` (the
21
+ * placed block's id — stable across renders). The result is rendered by the
22
+ * normal block-render pipeline.
16
23
  */
17
24
  export declare function resolveComposedTemplate(def: ComposedBlockDefinition, props: Record<string, unknown> | undefined, instanceId: string): ContentBlock[];
18
25
  /**
@@ -16,6 +16,8 @@ export interface PageState {
16
16
  title?: string;
17
17
  path?: string;
18
18
  meta?: Record<string, unknown>;
19
+ /** The page's layout (key into the app's `layouts` map). */
20
+ layout?: string;
19
21
  /** Set on paginated variants: which chunk of the page's paginated query this is. */
20
22
  pagination?: {
21
23
  page: number;
@@ -56,7 +58,14 @@ export interface GeneratePageOptions {
56
58
  locales?: LocalesConfig;
57
59
  baseUrl?: string;
58
60
  path?: string;
59
- /** Rewrite `/assets/` to this base when set. */
61
+ /**
62
+ * Serve build assets from this base URL (a CDN origin like
63
+ * `https://cdn.example.com`). Root-relative `/assets/…` references in the
64
+ * HTML are prefixed with it — `/assets/x.js` → `<assetsUrl>/assets/x.js` — so
65
+ * the built `assets/` folder can live on a CDN. A trailing slash is ignored,
66
+ * and an already-absolute `https://…/assets/` URL is left untouched. Uploaded
67
+ * media (`/media/…`) is rewritten by the export's `onFile`, not here.
68
+ */
60
69
  assetsUrl?: string;
61
70
  /**
62
71
  * Extra `<link>` tags for this page's content, injected before `</head>` —
@@ -1,7 +1,7 @@
1
1
  export type { Block, ContentBlock, ComposedBlockDefinition, ComposerComponentDefinition, ComposerComponentEntry, ComposerManifest, ComposerClassDefinition, ComposerClassEntry, ComposerClassDef, ComposerElementKind, ComposerBreakpoints, PropBinding, DataEntry, DataScope, PageMeta, State, PageLink, VirtualPage, } from './types';
2
2
  export { normalizeClassManifest } from './composer-manifest';
3
3
  export { normalizeLocales, isLocale, parseLocalePath, localePath, localeLabel, type LocalesConfig, type LocalesOption, } from './locale';
4
- export { resolveComposedTemplate, resolveBindings, templateBlockIds, isBinding, } from './compose';
4
+ export { resolveComposedTemplate, resolveBindings, lookupBinding, templateBlockIds, isBinding, } from './compose';
5
5
  export { type FieldType, type ImageCropConfig, type RegisterAlias, builtinFields, registerFieldSchemas, getFieldDefault, areFieldSchemasRegistered, } from './fields';
6
6
  export { getDefaultValue, passDefaultValue, buildPreviewData, mergePreviewData, walkTree, walkSchema, getValueByPath, } from './schema';
7
7
  export { generatePage, generateProject, passDataToHTML, serializeState, type GeneratePageOptions, type GenerateProjectOptions, type PageState, type RenderResult, } from './generate-page';
@@ -12,6 +12,11 @@ export interface PageDoc {
12
12
  * static export. Absent/false for published pages.
13
13
  */
14
14
  draft?: boolean;
15
+ /**
16
+ * The page's layout — a key into the app's `layouts` map. Absent = the
17
+ * default layout. Base-owned: translation files never carry it.
18
+ */
19
+ layout?: string;
15
20
  meta?: Record<string, unknown>;
16
21
  /** Page-scoped data overrides (defineData). */
17
22
  data: Record<string, unknown>;
@@ -11,6 +11,13 @@ export interface PageMeta {
11
11
  title?: string;
12
12
  path?: string;
13
13
  meta?: Record<string, unknown>;
14
+ /**
15
+ * The page's layout — a key into the app's `layouts` map (`defineMechanicaApp`).
16
+ * Authored as top-level `layout:` frontmatter in the `.page.md`. Absent = the
17
+ * default layout (the map's first entry). Base-owned on multi-language sites:
18
+ * translations always inherit it.
19
+ */
20
+ layout?: string;
14
21
  /**
15
22
  * Set on paginated variants of a page (`/blog/2`, …): which chunk of its
16
23
  * paginated query this URL shows. Page 1 is the base path and carries none.
@@ -49,6 +56,19 @@ export interface Block {
49
56
  hidden?: boolean;
50
57
  /** Available only in dev, stripped from production output. */
51
58
  devOnly?: boolean;
59
+ /**
60
+ * A block that *is* a whole page (a feedback form, a 404, a legal page).
61
+ * Offered under a "Pages" palette group only while the page is still empty,
62
+ * and never on a page that already has content — so page-shaped blocks stop
63
+ * polluting the palette everywhere else. Placed blocks always keep rendering.
64
+ */
65
+ standalone?: boolean;
66
+ /**
67
+ * Restrict the block to pages using these layouts (keys of the app's
68
+ * `layouts` map). Omitted = offered on every layout. Like `folders`, this
69
+ * only filters the palette — placed blocks always render.
70
+ */
71
+ layouts?: string[];
52
72
  /**
53
73
  * Restrict the block to pages under these folders (folder paths relative to
54
74
  * `pages/`, e.g. `'docs'`; nested folders match by prefix). Omitted = offered
@@ -192,6 +212,20 @@ export interface ComposedBlockDefinition {
192
212
  icon?: string;
193
213
  /** Palette grouping; defaults to a "Site blocks" group in the editor. */
194
214
  category?: string;
215
+ /**
216
+ * Hidden from the palette (like `Block.hidden`). Set on one-off blocks —
217
+ * e.g. a block created to design a single page in the composer — so they
218
+ * don't appear under "Site blocks" on every other page. Placed instances
219
+ * keep rendering; untick in the composer settings to promote it to reusable.
220
+ */
221
+ hidden?: boolean;
222
+ /**
223
+ * A whole-page block (like `Block.standalone`): offered in the empty page's
224
+ * "Start this page" palette group and the Page setup pane's Page-block
225
+ * select, never alongside content blocks. Toggled in the composer settings —
226
+ * a designer-built 404 or coming-soon page.
227
+ */
228
+ standalone?: boolean;
195
229
  /**
196
230
  * compact-json-schema for the props exposed out of the template (§ prop
197
231
  * bindings). Placed instances get an auto-generated settings form from this,
@@ -233,6 +267,14 @@ export interface DataEntry {
233
267
  title?: string;
234
268
  /** compact-json-schema describing the data shape. */
235
269
  props?: Record<string, unknown>;
270
+ /**
271
+ * Restrict the entry to pages under this folder (a folder path relative to
272
+ * `pages/`, e.g. `'examples'`; nested folders match by prefix). Omitted =
273
+ * offered on every page. This only filters what the editor's Data dialog
274
+ * *draws* — the value's scope (site / folder / page) and where it is stored
275
+ * are unaffected, so a page-scoped value still lives in the page file.
276
+ */
277
+ folder?: string;
236
278
  /**
237
279
  * Translate this entry per locale (multi-language sites). Its site/folder
238
280
  * value is stored per locale (with fallback to the default locale) instead of
@@ -296,10 +338,12 @@ export interface VirtualPage {
296
338
  path: string;
297
339
  /** The block tree to render — usually one template block parameterized by `data`. */
298
340
  content: ContentBlock[];
299
- /** Page-scoped data (head, layout, block props). Site data still merges under it. */
341
+ /** Page-scoped data (head, block props). Site data still merges under it. */
300
342
  data?: Record<string, unknown>;
301
343
  /** Page meta: `title` (breadcrumb / SEO name), `noindex`, and `{{ page.meta.* }}` hints. */
302
344
  meta?: Record<string, unknown>;
345
+ /** The page's layout (key into the app's `layouts` map); absent = default. */
346
+ layout?: string;
303
347
  /** The locale this page renders in. Omit for the default locale. */
304
348
  locale?: string;
305
349
  /** Every locale this logical page exists in — for hreflang alternates + language switchers. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mechanica-shared",
3
- "version": "2.0.0-alpha.8",
3
+ "version": "2.0.1",
4
4
  "description": "DOM-free types, schema helpers and page-generation core shared across Mechanica",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,27 +9,22 @@
9
9
  },
10
10
  "type": "module",
11
11
  "publishConfig": {
12
- "access": "public",
13
- "tag": "next"
12
+ "access": "public"
14
13
  },
15
14
  "files": [
16
- "src",
17
15
  "dist"
18
16
  ],
19
17
  "exports": {
20
18
  ".": {
21
19
  "types": "./dist/types/index.d.ts",
22
- "bun": "./src/index.ts",
23
20
  "import": "./dist/index.js"
24
21
  },
25
22
  "./page-format": {
26
23
  "types": "./dist/types/page-format.d.ts",
27
- "bun": "./src/page-format.ts",
28
24
  "import": "./dist/page-format.js"
29
25
  },
30
26
  "./block-format": {
31
27
  "types": "./dist/types/block-format.d.ts",
32
- "bun": "./src/block-format.ts",
33
28
  "import": "./dist/block-format.js"
34
29
  }
35
30
  },
@@ -1,100 +0,0 @@
1
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
2
- import type { ComposedBlockDefinition, ContentBlock } from './types'
3
-
4
- /**
5
- * Codec for the composed-block file format (`.mech/blocks/<id>.block.yml`) — a
6
- * single YAML document holding a {@link ComposedBlockDefinition}. Kept a
7
- * separate entry point (`mechanica-shared/block-format`), deliberately NOT
8
- * re-exported from the barrel: it pulls in the YAML parser, and the barrel is
9
- * imported by the client runtime, which never parses these files (only the dev
10
- * store, the plugin's collect step, and the CLI do). Mirrors the `page-format`
11
- * rule. See PLAN.md § 3.
12
- */
13
-
14
- /** Thrown by {@link parseComposedBlock} with a human-readable reason. */
15
- export class ComposedBlockParseError extends Error {
16
- constructor(message: string) {
17
- super(message)
18
- this.name = 'ComposedBlockParseError'
19
- Object.setPrototypeOf(this, ComposedBlockParseError.prototype)
20
- }
21
- }
22
-
23
- function isPlainObject(value: unknown): value is Record<string, unknown> {
24
- return typeof value === 'object' && value !== null && !Array.isArray(value)
25
- }
26
-
27
- /** Validate the minimal shape of a content node (recurses into children). */
28
- function assertContentBlock(node: unknown, where: string): asserts node is ContentBlock {
29
- if (!isPlainObject(node)) throw new ComposedBlockParseError(`${where}: expected a mapping`)
30
- if (typeof node.blockId !== 'string' || node.blockId === '') {
31
- throw new ComposedBlockParseError(`${where}: missing "blockId"`)
32
- }
33
- if (node.data !== undefined && !isPlainObject(node.data)) {
34
- throw new ComposedBlockParseError(`${where}: "data" must be a mapping`)
35
- }
36
- const children = node.children
37
- if (children === undefined) return
38
- if (Array.isArray(children)) {
39
- children.forEach((child, i) => assertContentBlock(child, `${where} › child ${i}`))
40
- } else if (isPlainObject(children)) {
41
- for (const [slot, list] of Object.entries(children)) {
42
- if (!Array.isArray(list)) throw new ComposedBlockParseError(`${where}: slot "${slot}" must be a list`)
43
- list.forEach((child, i) => assertContentBlock(child, `${where} › ${slot}[${i}]`))
44
- }
45
- } else {
46
- throw new ComposedBlockParseError(`${where}: "children" must be a list or a slot mapping`)
47
- }
48
- }
49
-
50
- /**
51
- * Parse a `.block.yml` document into a {@link ComposedBlockDefinition}.
52
- *
53
- * @param fallbackId Used as the id when the document omits one — the store and
54
- * plugin pass the filename base, so a hand-authored file may
55
- * omit `id` and be identified by its filename.
56
- */
57
- export function parseComposedBlock(text: string, fallbackId?: string): ComposedBlockDefinition {
58
- let doc: unknown
59
- try {
60
- doc = parseYaml(text)
61
- } catch (error) {
62
- throw new ComposedBlockParseError(`invalid YAML: ${error instanceof Error ? error.message : error}`)
63
- }
64
- if (doc == null) doc = {}
65
- if (!isPlainObject(doc)) throw new ComposedBlockParseError('expected a top-level mapping')
66
-
67
- const id = typeof doc.id === 'string' && doc.id !== '' ? doc.id : fallbackId
68
- if (!id) throw new ComposedBlockParseError('missing "id"')
69
- if (typeof doc.name !== 'string' || doc.name === '') throw new ComposedBlockParseError('missing "name"')
70
-
71
- const template = doc.template ?? []
72
- if (!Array.isArray(template)) throw new ComposedBlockParseError('"template" must be a list')
73
- template.forEach((node, i) => assertContentBlock(node, `template[${i}]`))
74
-
75
- if (doc.props !== undefined && !isPlainObject(doc.props)) {
76
- throw new ComposedBlockParseError('"props" must be a mapping')
77
- }
78
- if (doc.previewData !== undefined && !isPlainObject(doc.previewData)) {
79
- throw new ComposedBlockParseError('"previewData" must be a mapping')
80
- }
81
-
82
- const def: ComposedBlockDefinition = { id, name: doc.name, template: template as ContentBlock[] }
83
- if (typeof doc.icon === 'string') def.icon = doc.icon
84
- if (typeof doc.category === 'string') def.category = doc.category
85
- if (isPlainObject(doc.props)) def.props = doc.props
86
- if (isPlainObject(doc.previewData)) def.previewData = doc.previewData
87
- return def
88
- }
89
-
90
- /** Serialize a {@link ComposedBlockDefinition} to a `.block.yml` document. */
91
- export function serializeComposedBlock(def: ComposedBlockDefinition): string {
92
- // Build an ordered object so the file reads header-first, tree-last.
93
- const ordered: Record<string, unknown> = { id: def.id, name: def.name }
94
- if (def.icon) ordered.icon = def.icon
95
- if (def.category) ordered.category = def.category
96
- if (def.props && Object.keys(def.props).length) ordered.props = def.props
97
- if (def.previewData && Object.keys(def.previewData).length) ordered.previewData = def.previewData
98
- ordered.template = def.template ?? []
99
- return stringifyYaml(ordered, { lineWidth: 0 })
100
- }
package/src/compose.ts DELETED
@@ -1,120 +0,0 @@
1
- import type { ComposedBlockDefinition, ContentBlock, PropBinding } from './types'
2
-
3
- /**
4
- * Expansion of composed blocks into a concrete content tree. DOM- and
5
- * framework-free so the render side (client, SSR, export) shares one
6
- * implementation. See PLAN.md § 2.3 / § 4.4.
7
- */
8
-
9
- /** Reserved data keys the composer engine consumes (never passed as props). */
10
- const IF_KEY = '$if'
11
-
12
- /** Whether a value is a prop binding (`{ $bind: 'name' }`). */
13
- export function isBinding(value: unknown): value is PropBinding {
14
- return (
15
- typeof value === 'object' &&
16
- value !== null &&
17
- !Array.isArray(value) &&
18
- typeof (value as { $bind?: unknown }).$bind === 'string'
19
- )
20
- }
21
-
22
- /**
23
- * Deep-resolve a data value: substitute every `{ $bind: name }` with the named
24
- * prop, recursing through arrays and plain objects (so a bound value nested in
25
- * e.g. a `smartLink` object resolves too). Non-binding scalars pass through;
26
- * everything is cloned, so the returned value never aliases the template.
27
- */
28
- export function resolveBindings(value: unknown, props: Record<string, unknown>): unknown {
29
- if (isBinding(value)) return props[value.$bind]
30
- if (Array.isArray(value)) return value.map((item) => resolveBindings(item, props))
31
- if (value !== null && typeof value === 'object') {
32
- const out: Record<string, unknown> = {}
33
- for (const [key, item] of Object.entries(value)) out[key] = resolveBindings(item, props)
34
- return out
35
- }
36
- return value
37
- }
38
-
39
- /** Resolve one template node, or `null` when a falsy `$if` binding drops it. */
40
- function resolveNode(
41
- node: ContentBlock,
42
- props: Record<string, unknown>,
43
- prefix: string,
44
- index: number,
45
- ): ContentBlock | null {
46
- const raw = node.data ?? {}
47
- // `$if` gates the node's presence — resolve it before anything else so a
48
- // hidden element costs nothing downstream.
49
- if (IF_KEY in raw && !resolveBindings(raw[IF_KEY], props)) return null
50
-
51
- const data: Record<string, unknown> = {}
52
- for (const [key, value] of Object.entries(raw)) {
53
- if (key === IF_KEY) continue
54
- data[key] = resolveBindings(value, props)
55
- }
56
-
57
- // Namespace ids under the placed instance so two placements of the same
58
- // composed block produce distinct, stable vnode keys.
59
- const id = `${prefix}:${node.id ?? index}`
60
- const resolved: ContentBlock = { id, blockId: node.blockId, data }
61
- if (node.v != null) resolved.v = node.v
62
- if (node.children) resolved.children = resolveChildren(node.children, props, id)
63
- return resolved
64
- }
65
-
66
- function resolveList(list: ContentBlock[], props: Record<string, unknown>, prefix: string): ContentBlock[] {
67
- const out: ContentBlock[] = []
68
- list.forEach((child, index) => {
69
- const resolved = resolveNode(child, props, prefix, index)
70
- if (resolved) out.push(resolved)
71
- })
72
- return out
73
- }
74
-
75
- function resolveChildren(
76
- children: NonNullable<ContentBlock['children']>,
77
- props: Record<string, unknown>,
78
- prefix: string,
79
- ): ContentBlock['children'] {
80
- if (Array.isArray(children)) return resolveList(children, props, `${prefix}/d`)
81
- const map: Record<string, ContentBlock[]> = {}
82
- for (const [name, list] of Object.entries(children)) {
83
- map[name] = resolveList(list, props, `${prefix}/${name}`)
84
- }
85
- return map
86
- }
87
-
88
- /**
89
- * Expand a composed block's template into a concrete content tree: substitute
90
- * `$bind` values from `props`, drop `$if`-hidden nodes, and namespace every
91
- * node id under `instanceId` (the placed block's id — stable across renders).
92
- * The result is rendered by the normal block-render pipeline.
93
- */
94
- export function resolveComposedTemplate(
95
- def: ComposedBlockDefinition,
96
- props: Record<string, unknown> | undefined,
97
- instanceId: string,
98
- ): ContentBlock[] {
99
- return resolveList(def.template ?? [], props ?? {}, instanceId)
100
- }
101
-
102
- /**
103
- * Block ids referenced anywhere in a composed template (recursing into slot
104
- * children). Used to expand a page's used-block set so the underlying compiled
105
- * blocks' chunks load before a composed block mounts. Element blocks (`mech:*`)
106
- * ship with the runtime; they need no loader but are harmless to include.
107
- */
108
- export function templateBlockIds(def: ComposedBlockDefinition): Set<string> {
109
- const ids = new Set<string>()
110
- const walk = (list: ContentBlock[]): void => {
111
- for (const node of list) {
112
- ids.add(node.blockId)
113
- if (!node.children) continue
114
- if (Array.isArray(node.children)) walk(node.children)
115
- else for (const inner of Object.values(node.children)) walk(inner)
116
- }
117
- }
118
- walk(def.template ?? [])
119
- return ids
120
- }
@@ -1,45 +0,0 @@
1
- import type {
2
- ComposerClassDef,
3
- ComposerClassEntry,
4
- ComposerElementKind,
5
- } from './types'
6
-
7
- /**
8
- * Normalize the Block Composer manifest's `classes` record into the flat list
9
- * the composer consumes — unfolding the string / array shorthands and defaulting
10
- * each title to its class name. Pure and DOM-free (the barrel is client-safe);
11
- * called from the generated `virtual:mechanica/components` module at runtime.
12
- * See COMPOSER-MANIFEST.md § 2.
13
- *
14
- * Shorthands:
15
- * - `'text'` → `{ on: ['text'] }`
16
- * - `['frame', 'image']` → `{ on: ['frame', 'image'] }`
17
- * - `{ title?, on, group? }` → as written (only the full form carries a group)
18
- */
19
- export function normalizeClassManifest(
20
- classes: Record<string, ComposerClassEntry> | undefined,
21
- ): ComposerClassDef[] {
22
- if (!classes) return []
23
- const out: ComposerClassDef[] = []
24
- for (const cls in classes) {
25
- const entry = classes[cls]
26
- if (entry == null) continue
27
- const kinds = classKinds(entry)
28
- if (!kinds.length) continue
29
- const full = typeof entry === 'object' && !Array.isArray(entry) ? entry : null
30
- const def: ComposerClassDef = { cls, title: full?.title || cls, kinds }
31
- if (full?.group) def.group = full.group
32
- out.push(def)
33
- }
34
- return out
35
- }
36
-
37
- const KINDS: readonly ComposerElementKind[] = ['frame', 'text', 'image']
38
- const isKind = (v: unknown): v is ComposerElementKind => KINDS.includes(v as ComposerElementKind)
39
-
40
- /** The element kinds a `classes` entry applies to (any shorthand), deduped. */
41
- function classKinds(entry: ComposerClassEntry): ComposerElementKind[] {
42
- const raw = isKind(entry) ? [entry] : Array.isArray(entry) ? entry : entry.on
43
- const list = Array.isArray(raw) ? raw : [raw]
44
- return [...new Set(list.filter(isKind))]
45
- }