smoodly 0.0.62 → 0.0.63

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 (61) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/drift.js +3 -1
  3. package/dist/admin/editor/EditorView.js +65 -9
  4. package/dist/admin/editor/InsertPicker.d.ts +8 -27
  5. package/dist/admin/editor/InsertPicker.js +56 -47
  6. package/dist/admin/editor/SamplePreview.d.ts +7 -0
  7. package/dist/admin/editor/SamplePreview.js +86 -0
  8. package/dist/admin/editor/SectionIcon.d.ts +4 -0
  9. package/dist/admin/editor/SectionIcon.js +8 -0
  10. package/dist/admin/editor/StructureView.d.ts +2 -2
  11. package/dist/admin/editor/StructureView.js +2 -1
  12. package/dist/admin/editor/canvas-address.d.ts +2 -0
  13. package/dist/admin/editor/canvas-address.js +3 -1
  14. package/dist/admin/editor/picker-rows.d.ts +33 -0
  15. package/dist/admin/editor/picker-rows.js +38 -0
  16. package/dist/admin/editor/sample-frame.d.ts +36 -0
  17. package/dist/admin/editor/sample-frame.js +58 -0
  18. package/dist/admin/editor/sample-picks.d.ts +34 -0
  19. package/dist/admin/editor/sample-picks.js +49 -0
  20. package/dist/admin/editor/view-memory.d.ts +5 -0
  21. package/dist/admin/editor/view-memory.js +19 -0
  22. package/dist/admin/files.generated.js +1 -1
  23. package/dist/admin/forms/MediaWidget.d.ts +1 -1
  24. package/dist/admin/forms/MediaWidget.js +10 -2
  25. package/dist/admin/ops.d.ts +5 -0
  26. package/dist/admin/shell/GlobalForm.js +1 -1
  27. package/dist/admin/shell/GlobalList.js +1 -1
  28. package/dist/admin/shell/NavBar.js +1 -1
  29. package/dist/admin/shell/PagesList.js +4 -4
  30. package/dist/admin/shell/console-links.js +1 -1
  31. package/dist/admin/shell/health-list.js +1 -1
  32. package/dist/admin/tree-ops.d.ts +11 -4
  33. package/dist/admin/tree-ops.js +28 -17
  34. package/dist/admin/ui/theme.js +32 -13
  35. package/dist/admin/validate.js +3 -2
  36. package/dist/api-client.js +12 -2
  37. package/dist/astro/middleware.js +2 -0
  38. package/dist/astro/shell.d.ts +3 -0
  39. package/dist/astro/shell.js +8 -1
  40. package/dist/canvas-marker.d.ts +5 -0
  41. package/dist/canvas-marker.js +18 -6
  42. package/dist/conditions.js +5 -2
  43. package/dist/media.d.ts +15 -2
  44. package/dist/media.js +68 -10
  45. package/dist/next/page-renderer.js +12 -5
  46. package/dist/resolve.d.ts +6 -1
  47. package/dist/resolve.js +7 -7
  48. package/dist/sample-fill.d.ts +40 -0
  49. package/dist/sample-fill.js +145 -0
  50. package/dist/sample-tree.d.ts +30 -0
  51. package/dist/sample-tree.js +76 -0
  52. package/dist/site.d.ts +13 -0
  53. package/dist/site.js +10 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/docs/content-modeling/sections.md +19 -3
  57. package/docs/content-modeling/zones.md +2 -2
  58. package/docs/getting-started/working-together.md +1 -1
  59. package/docs/globals/defining.md +1 -1
  60. package/docs/globals/header-and-footer.md +1 -1
  61. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { isImagePlaceholder } from "./media.js";
1
2
  export function when(path) {
2
3
  if (typeof path !== "string" || path === "")
3
4
  throw new Error("smoodly: f.when() needs a field path.");
@@ -22,10 +23,12 @@ function lookup(path, scope) {
22
23
  const holder = ns === null ? scope.self : scope[ns];
23
24
  return holder?.[key];
24
25
  }
25
- /** Empty as the validator sees it, plus a media value with no asset. */
26
+ /** Empty as the validator sees it, plus a media value with no asset: an image placeholder included, since a condition
27
+ * follows what will be published (spec 2026-11-14 filled samples). */
26
28
  const isEmptyValue = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0) ||
27
29
  (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) ||
28
- (typeof v === "object" && !Array.isArray(v) && "asset" in v && typeof v.asset !== "string");
30
+ (typeof v === "object" && !Array.isArray(v) && "asset" in v && typeof v.asset !== "string") ||
31
+ isImagePlaceholder(v);
29
32
  export function evaluate(c, scope) {
30
33
  const v = lookup(c.path, scope);
31
34
  switch (c.op) {
package/dist/media.d.ts CHANGED
@@ -137,6 +137,14 @@ export declare const cropAspect: (crop: Crop, imageAspect: number) => number;
137
137
  export declare const isMediaValue: (v: unknown) => v is {
138
138
  asset: string;
139
139
  };
140
+ /** What a fresh node stores in an image field its `sample` leaves out (spec 2026-11-14 filled samples): no asset, so
141
+ * no asset reference. A draft read draws it; a published read, publish and a condition take it as unset. */
142
+ export type ImagePlaceholder = {
143
+ placeholder: true;
144
+ };
145
+ /** Frozen: a caller stores a copy (`{ ...IMAGE_PLACEHOLDER }`). */
146
+ export declare const IMAGE_PLACEHOLDER: ImagePlaceholder;
147
+ export declare const isImagePlaceholder: (v: unknown) => v is ImagePlaceholder;
140
148
  /** Every asset id a value set references, walking lists and objects —
141
149
  * unlike refs, media may sit inside a container (spec §8). */
142
150
  export declare function collectAssetIds(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, out: Set<string>, elements?: ElementSchemas): void;
@@ -153,11 +161,16 @@ export declare function isCrop(c: unknown): c is Crop;
153
161
  export declare function framedCrop(value: Pick<ImageValue, "crop" | "crops">, names: string[], name: string, ratio: string, imageAspect: number): Crop | undefined;
154
162
  /** What a crop nobody framed shows: the largest rectangle at its ratio around the hotspot, as fractions. */
155
163
  export declare function followingCrop(ratio: string, hotspot: Hotspot | undefined, width: number, height: number): Crop;
164
+ /** What a draft read hands a view for a stored placeholder (spec 2026-11-14 filled samples): a picture at the field's
165
+ * own aspect (its first named crop's, else its `aspect`, else 3:2), 1200 wide, with no `cut`: the view draws the
166
+ * address as it is. A field with named crops carries every crop, as an unframed photo would. */
167
+ export declare function placeholderImage(d: Descriptor, defaults?: ImageDefaults): ResolvedImage;
156
168
  /** Replace every media value in place with its resolved object — or delete
157
169
  * it when the asset is missing or pending, so `image?.url` guards hold. With
158
170
  * `cuts`, every raster image resolved pushes its cut request (signCuts fills them); `defaults` are the project's
159
- * image quality and cap, under a field's own. */
160
- export declare function applyAssets(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, assets: Map<string, AssetRecord>, elements?: ElementSchemas, cuts?: CutRequest[], defaults?: ImageDefaults): void;
171
+ * image quality and cap, under a field's own. `placeholders` is the read's kind (spec 2026-11-14 filled samples):
172
+ * true in a draft read, where a stored image placeholder becomes a picture; false, it is deleted like a gone asset. */
173
+ export declare function applyAssets(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, assets: Map<string, AssetRecord>, elements?: ElementSchemas, cuts?: CutRequest[], defaults?: ImageDefaults, placeholders?: boolean): void;
161
174
  /** Signs each distinct rectangle once and fills every request for it: two fields framing one photo the same way share a
162
175
  * signature, an address and a cache entry. */
163
176
  export declare function signCuts(requests: CutRequest[], signer: CutSigner): Promise<void>;
package/dist/media.js CHANGED
@@ -36,6 +36,10 @@ export function parseAspect(s) {
36
36
  /** The crop's own pixel ratio, given the image's. */
37
37
  export const cropAspect = (crop, imageAspect) => (crop.width * imageAspect) / crop.height;
38
38
  export const isMediaValue = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && typeof v.asset === "string";
39
+ /** Frozen: a caller stores a copy (`{ ...IMAGE_PLACEHOLDER }`). */
40
+ export const IMAGE_PLACEHOLDER = Object.freeze({ placeholder: true });
41
+ export const isImagePlaceholder = (v) => typeof v === "object" && v !== null && !Array.isArray(v) &&
42
+ v.placeholder === true && typeof v.asset !== "string";
39
43
  /** Every asset id a value set references, walking lists and objects —
40
44
  * unlike refs, media may sit inside a container (spec §8). */
41
45
  export function collectAssetIds(values, descriptors, out, elements = {}) {
@@ -187,7 +191,60 @@ function resolvedImage(asset, value, d, cuts, defaults) {
187
191
  cuts.push({ path: asset.path, rect: cutRect(image), set: (cut) => { image.cut = cut; } });
188
192
  return image;
189
193
  }
190
- function resolveOne(value, d, assets, cuts, defaults) {
194
+ const PLACEHOLDER_WIDTH = 1200;
195
+ const PLACEHOLDER_ASPECT = "3:2";
196
+ /** A neutral rectangle with a picture mark, as a `data:` address: nothing to fetch, and never the same as a photo. */
197
+ function placeholderUrl(width, height) {
198
+ const m = Math.round(Math.min(width, height) / 5);
199
+ const x = Math.round(width / 2 - m), y = Math.round(height / 2 - m * 0.75);
200
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
201
+ `<rect width="${width}" height="${height}" fill="#e4e4e7"/>` +
202
+ `<g transform="translate(${x} ${y})" fill="none" stroke="#a1a1aa" stroke-width="${Math.max(2, Math.round(m / 12))}" stroke-linejoin="round">` +
203
+ `<rect width="${m * 2}" height="${Math.round(m * 1.5)}" rx="${Math.round(m / 8)}"/>` +
204
+ `<circle cx="${Math.round(m * 0.55)}" cy="${Math.round(m * 0.45)}" r="${Math.round(m / 6)}"/>` +
205
+ `<path d="M0 ${Math.round(m * 1.2)} L${Math.round(m * 0.7)} ${Math.round(m * 0.75)} L${Math.round(m * 1.15)} ${Math.round(m * 1.1)} L${Math.round(m * 1.5)} ${Math.round(m * 0.85)} L${m * 2} ${Math.round(m * 1.25)}"/>` +
206
+ `</g></svg>`;
207
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
208
+ }
209
+ /** What a draft read hands a view for a stored placeholder (spec 2026-11-14 filled samples): a picture at the field's
210
+ * own aspect (its first named crop's, else its `aspect`, else 3:2), 1200 wide, with no `cut`: the view draws the
211
+ * address as it is. A field with named crops carries every crop, as an unframed photo would. */
212
+ export function placeholderImage(d, defaults) {
213
+ const named = namedCropsOf(d);
214
+ const declared = Array.isArray(d.aspect) ? d.aspect[0] : d.aspect;
215
+ const aspect = named ? Object.values(named)[0] : typeof declared === "string" ? declared : undefined;
216
+ let ratio;
217
+ try {
218
+ ratio = parseAspect(aspect ?? PLACEHOLDER_ASPECT);
219
+ }
220
+ catch {
221
+ ratio = parseAspect(PLACEHOLDER_ASPECT);
222
+ }
223
+ const width = PLACEHOLDER_WIDTH;
224
+ const height = Math.round(width / ratio);
225
+ const image = {
226
+ url: placeholderUrl(width, height), width, height, mime: "image/svg+xml", alt: "",
227
+ ...(aspect && !named ? { aspect } : {}),
228
+ ...delivery(d, defaults),
229
+ };
230
+ if (named) {
231
+ const crops = {};
232
+ Object.entries(named).forEach(([name, r], i) => {
233
+ const rect = hotspotRect(parseAspect(r), undefined, width, height);
234
+ crops[name] = { crop: { left: rect.left / width, top: rect.top / height, width: rect.width / width, height: rect.height / height }, aspect: r, width: rect.width, height: rect.height };
235
+ if (i === 0) {
236
+ image.crop = crops[name].crop;
237
+ image.aspect = r;
238
+ }
239
+ });
240
+ image.crops = crops;
241
+ }
242
+ return image;
243
+ }
244
+ function resolveOne(value, d, assets, cuts, defaults, placeholders = false) {
245
+ // A stored placeholder (spec 2026-11-14 filled samples): a picture in a read that asks for it, unset in any other.
246
+ if (d.type === "image" && isImagePlaceholder(value))
247
+ return placeholders ? placeholderImage(d, defaults) : undefined;
191
248
  if (!isMediaValue(value))
192
249
  return undefined;
193
250
  const asset = assets.get(value.asset);
@@ -214,8 +271,9 @@ function resolveOne(value, d, assets, cuts, defaults) {
214
271
  /** Replace every media value in place with its resolved object — or delete
215
272
  * it when the asset is missing or pending, so `image?.url` guards hold. With
216
273
  * `cuts`, every raster image resolved pushes its cut request (signCuts fills them); `defaults` are the project's
217
- * image quality and cap, under a field's own. */
218
- export function applyAssets(values, descriptors, assets, elements = {}, cuts, defaults) {
274
+ * image quality and cap, under a field's own. `placeholders` is the read's kind (spec 2026-11-14 filled samples):
275
+ * true in a draft read, where a stored image placeholder becomes a picture; false, it is deleted like a gone asset. */
276
+ export function applyAssets(values, descriptors, assets, elements = {}, cuts, defaults, placeholders = false) {
219
277
  if (!values)
220
278
  return;
221
279
  for (const [key, d] of Object.entries(descriptors)) {
@@ -223,7 +281,7 @@ export function applyAssets(values, descriptors, assets, elements = {}, cuts, de
223
281
  if (value == null)
224
282
  continue;
225
283
  if (MEDIA_TYPES.has(d.type)) {
226
- const resolved = resolveOne(value, d, assets, cuts, defaults);
284
+ const resolved = resolveOne(value, d, assets, cuts, defaults, placeholders);
227
285
  if (resolved === undefined)
228
286
  delete values[key];
229
287
  else
@@ -232,7 +290,7 @@ export function applyAssets(values, descriptors, assets, elements = {}, cuts, de
232
290
  else if (d.type === "richtext") {
233
291
  for (const node of richtextBlocks(value)) {
234
292
  if (node.type === IMAGE_NODE) {
235
- const resolved = resolveOne(node.attrs.image, { type: "image" }, assets, cuts, defaults);
293
+ const resolved = resolveOne(node.attrs.image, { type: "image" }, assets, cuts, defaults, placeholders);
236
294
  if (resolved === undefined)
237
295
  delete node.attrs.image;
238
296
  else
@@ -242,27 +300,27 @@ export function applyAssets(values, descriptors, assets, elements = {}, cuts, de
242
300
  const schema = elementOf(node, elements);
243
301
  if (schema)
244
302
  for (const set of elementSets(node, schema))
245
- applyAssets(set.values, set.descriptors, assets, elements, cuts, defaults);
303
+ applyAssets(set.values, set.descriptors, assets, elements, cuts, defaults, placeholders);
246
304
  }
247
305
  }
248
306
  }
249
307
  else if (d.type === "object" && typeof value === "object" && !Array.isArray(value)) {
250
- applyAssets(value, d.fields, assets, elements, cuts, defaults);
308
+ applyAssets(value, d.fields, assets, elements, cuts, defaults, placeholders);
251
309
  }
252
310
  else if (d.type === "list" && Array.isArray(value)) {
253
311
  const item = d.item;
254
312
  if (MEDIA_TYPES.has(item.type)) {
255
- values[key] = value.map((v) => resolveOne(v, item, assets, cuts, defaults)).filter((v) => v !== undefined);
313
+ values[key] = value.map((v) => resolveOne(v, item, assets, cuts, defaults, placeholders)).filter((v) => v !== undefined);
256
314
  }
257
315
  else if (item.type === "object") {
258
316
  for (const v of value)
259
317
  if (v && typeof v === "object")
260
- applyAssets(v, item.fields, assets, elements, cuts, defaults);
318
+ applyAssets(v, item.fields, assets, elements, cuts, defaults, placeholders);
261
319
  }
262
320
  else if (item.type === "richtext") {
263
321
  // Resolves in place inside the document object `v`; the wrapper is throwaway.
264
322
  for (const v of value)
265
- applyAssets({ v }, { v: item }, assets, elements, cuts, defaults);
323
+ applyAssets({ v }, { v: item }, assets, elements, cuts, defaults, placeholders);
266
324
  }
267
325
  }
268
326
  }
@@ -8,7 +8,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
8
8
  import { Suspense } from "react";
9
9
  import { draftMode } from "next/headers";
10
10
  import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
11
- import { markCanvasPath, readCanvasMarker } from "../canvas-marker.js";
11
+ import { markCanvasPath, readCanvasMarker, SAMPLES_PARAM } from "../canvas-marker.js";
12
+ import { samplesHit } from "../sample-tree.js";
12
13
  import { BRIDGE_CSS } from "../marks.js";
13
14
  import { CanvasBridgeSlot, CanvasNetSlot } from "./canvas-slot.js";
14
15
  import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
@@ -115,7 +116,12 @@ export function createPageRenderer(options) {
115
116
  const editor = draft && (marker?.editor === true || params?.["smoodly-editor"] === "1");
116
117
  const version = !draft ? undefined
117
118
  : marker?.version ?? (typeof versionParam === "string" && versionParam !== "" ? versionParam : undefined);
118
- const flags = marker?.editor ? { editor: true } : marker?.version ? { version: marker.version } : null;
119
+ // The picker's samples (spec 2026-11-14 add section previews): the zone's rows drawn in place of the page's content.
120
+ const samplesParam = params?.[SAMPLES_PARAM];
121
+ const samples = !draft ? undefined
122
+ : marker?.samples ?? (typeof samplesParam === "string" && samplesParam !== "" ? samplesParam : undefined);
123
+ const flags = marker?.editor ? { editor: true } : marker?.version ? { version: marker.version }
124
+ : marker?.samples ? { samples: marker.samples } : null;
119
125
  const target = await site.locate(locale, path);
120
126
  // No document to tag: a redirect row, a miss or a code-owned page. Next caches the response like a
121
127
  // page, so it hears the addresses tag (reads.ts missUnit, decision 4).
@@ -127,16 +133,17 @@ export function createPageRenderer(options) {
127
133
  if (!target)
128
134
  await tagAddress();
129
135
  // A code-owned address with no record yet is served from code (spec 2026-09-30 tester findings §2).
130
- const hit = target ? await load(target, locale, path, version) : site.codePage(locale, path);
131
- if (!hit || !accept(hit)) {
136
+ const loaded = target ? await load(target, locale, path, version) : site.codePage(locale, path);
137
+ if (!loaded || !accept(loaded)) {
132
138
  if (target)
133
139
  await tagAddress();
134
140
  notFound();
135
141
  }
142
+ const hit = samples !== undefined ? await samplesHit(site, loaded, target, locale, path, samples) : loaded;
136
143
  // The draft marker tells the page editor's canvas diagnosis that Smoodly
137
144
  // rendered this response (spec 2026-09-27 shadowed-page detector §3);
138
145
  // React hoists the meta into the head. Never outside draft mode.
139
- return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx("style", { "data-smoodly-bridge": "", children: BRIDGE_CSS }) : null, editor ? _jsx(CanvasBridgeSlot, {}) : null, draft && !editor && !version ? previewPill({ route: options.previewRoute }) : null, render(hit, editor, locale)] }));
146
+ return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx("style", { "data-smoodly-bridge": "", children: BRIDGE_CSS }) : null, editor ? _jsx(CanvasBridgeSlot, {}) : null, draft && !editor && !version && samples === undefined ? previewPill({ route: options.previewRoute }) : null, render(hit, editor, locale)] }));
140
147
  }
141
148
  async function metadata(pathFor, opts, accept) {
142
149
  // generateMetadata runs alongside the page render, so it gates too. It runs no miss unit: Next renders
package/dist/resolve.d.ts CHANGED
@@ -47,6 +47,9 @@ type ResolveOptions = {
47
47
  page?: {
48
48
  fields: Record<string, Descriptor>;
49
49
  };
50
+ /** A draft read (spec 2026-11-14 filled samples): a stored image placeholder becomes a picture. Absent or false, a
51
+ * published read, it is dropped like an image whose asset is gone. */
52
+ placeholders?: boolean;
50
53
  /** The custom section's columns toolset (spec 2026-09-14 §6); the default when absent. The schema is always in the map. */
51
54
  custom?: {
52
55
  toolset?: Toolset;
@@ -54,7 +57,7 @@ type ResolveOptions = {
54
57
  };
55
58
  export declare function resolveTree(tree: PageTree, options: ResolveOptions): Promise<PageTree>;
56
59
  /** One value set's media, resolved in place. Exported for the site layer's global-item settings. */
57
- export declare function resolveMedia(values: Record<string, unknown>, descriptors: Record<string, Descriptor>, fetch: AssetFetcher, elements?: ElementSchemas, signer?: CutSigner, defaults?: ImageDefaults): Promise<void>;
60
+ export declare function resolveMedia(values: Record<string, unknown>, descriptors: Record<string, Descriptor>, fetch: AssetFetcher, elements?: ElementSchemas, signer?: CutSigner, defaults?: ImageDefaults, placeholders?: boolean): Promise<void>;
58
61
  export type ResolveFieldsOptions = {
59
62
  collections: CollectionSchema<any>[];
60
63
  fetch: EntryFetcher;
@@ -68,6 +71,8 @@ export type ResolveFieldsOptions = {
68
71
  /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
69
72
  elements?: SectionLike[];
70
73
  maxDepth?: number;
74
+ /** A draft read: a stored image placeholder becomes a picture (spec 2026-11-14 filled samples). */
75
+ placeholders?: boolean;
71
76
  };
72
77
  /** One entry's fields, refs hydrated — the entry page's counterpart of
73
78
  * resolveTree. Same rules, same batching, same cycle cut. */
package/dist/resolve.js CHANGED
@@ -62,7 +62,7 @@ export async function resolveTree(tree, options) {
62
62
  sets.push({ values: resolved.fields, descriptors: options.page.fields });
63
63
  if (resolved.meta)
64
64
  sets.push({ values: resolved.meta, descriptors: META_DESCRIPTORS });
65
- await resolveMediaSets([...sets, ...hydrated], options.assets, elementSchemas(options.elements), options.signer, options.images);
65
+ await resolveMediaSets([...sets, ...hydrated], options.assets, elementSchemas(options.elements), options.signer, options.images, options.placeholders);
66
66
  }
67
67
  return resolved;
68
68
  }
@@ -83,20 +83,20 @@ async function fetchAssets(fetch, ids) {
83
83
  return out;
84
84
  }
85
85
  /** One value set's media, resolved in place. Exported for the site layer's global-item settings. */
86
- export async function resolveMedia(values, descriptors, fetch, elements = {}, signer, defaults) {
87
- await resolveMediaSets([{ values, descriptors }], fetch, elements, signer, defaults);
86
+ export async function resolveMedia(values, descriptors, fetch, elements = {}, signer, defaults, placeholders) {
87
+ await resolveMediaSets([{ values, descriptors }], fetch, elements, signer, defaults, placeholders);
88
88
  }
89
89
  /** Many value sets, one asset round (plus one for posters): the tree's
90
90
  * nodes and every entry hydrated through a ref, or a list page's items.
91
91
  * Exported for the site layer's content API. */
92
- async function resolveMediaSets(sets, fetch, elements = {}, signer, defaults) {
92
+ async function resolveMediaSets(sets, fetch, elements = {}, signer, defaults, placeholders = false) {
93
93
  const ids = new Set();
94
94
  for (const { values, descriptors } of sets)
95
95
  collectAssetIds(values, descriptors, ids, elements);
96
96
  const assets = await fetchAssets(fetch, ids);
97
97
  const cuts = signer ? [] : undefined;
98
98
  for (const { values, descriptors } of sets)
99
- applyAssets(values, descriptors, assets, elements, cuts, defaults);
99
+ applyAssets(values, descriptors, assets, elements, cuts, defaults, placeholders);
100
100
  if (signer && cuts)
101
101
  await signCuts(cuts, signer);
102
102
  }
@@ -151,7 +151,7 @@ export async function resolveFields(fields, descriptors, options) {
151
151
  collectTasks(resolved, descriptors, tasks);
152
152
  const hydrated = await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
153
153
  if (options.assets)
154
- await resolveMediaSets([{ values: resolved, descriptors }, ...hydrated], options.assets, elementSchemas(options.elements), options.signer, options.images);
154
+ await resolveMediaSets([{ values: resolved, descriptors }, ...hydrated], options.assets, elementSchemas(options.elements), options.signer, options.images, options.placeholders);
155
155
  return resolved;
156
156
  }
157
157
  /** Many value sets (a list's entries), one resolution: every ref batched per
@@ -162,7 +162,7 @@ export async function resolveFieldSets(sets, options) {
162
162
  collectTasks(values, descriptors, tasks);
163
163
  const hydrated = await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
164
164
  if (options.assets)
165
- await resolveMediaSets([...sets, ...hydrated], options.assets, elementSchemas(options.elements), options.signer, options.images);
165
+ await resolveMediaSets([...sets, ...hydrated], options.assets, elementSchemas(options.elements), options.signer, options.images, options.placeholders);
166
166
  }
167
167
  /** Hydrates every task's refs in place and returns each hydrated entry
168
168
  * copy with its collection's descriptors — the media pass walks those
@@ -0,0 +1,40 @@
1
+ /** Collection name → entry ids, in the order to use them: the first published entries, read by the caller. */
2
+ export type SamplePicks = Record<string, string[]>;
3
+ /** `empty`: neither the sample nor generated values (the picker's switch, off). `images: false` stores no image
4
+ * placeholder: a service older than the placeholder would refuse to publish it. */
5
+ export type FreshOptions = {
6
+ picks?: SamplePicks;
7
+ empty?: boolean;
8
+ images?: boolean;
9
+ };
10
+ /** A field descriptor as registered or as serialized for the admin: the keys the rules read are the same in both. */
11
+ export type FieldLike = {
12
+ type: string;
13
+ optional?: unknown;
14
+ default?: unknown;
15
+ min?: unknown;
16
+ max?: unknown;
17
+ item?: unknown;
18
+ fields?: unknown;
19
+ toolset?: unknown;
20
+ target?: unknown;
21
+ options?: unknown;
22
+ };
23
+ export declare const LOREM_TITLE = "Lorem ipsum dolor sit";
24
+ export declare const LOREM_SENTENCE = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.";
25
+ /** How many entries of a collection the picks hold: the site's read and the admin's operation ask for this many. */
26
+ export declare const SAMPLE_PICK_LIMIT = 3;
27
+ /** A required choice shows its first option (FieldWidget renders no blank one), so a new node stores it too, and
28
+ * publish never says Required for a choice the editor sees made. */
29
+ export declare const firstChoice: (d: FieldLike) => unknown;
30
+ /** What is not content: a field's default, a required choice's first option, a boolean's false. */
31
+ export declare const starting: (d: FieldLike) => unknown;
32
+ /** The value for a field its sample and default leave out, or undefined to leave it as today. Text, richtext, a list
33
+ * and an object are generated when required only; an image whether required or not; a list of references whether
34
+ * required or not, a single reference when required. Every call builds new objects. */
35
+ export declare function generated(key: string, d: FieldLike, options?: FreshOptions): unknown;
36
+ /** The collections a top-level `ref` or `refList` field of these sections targets, unique, in first-seen order: what
37
+ * the caller reads picks for (the resolver follows references at a node's top level only). */
38
+ export declare function sampleCollections(sections: {
39
+ fields: Record<string, FieldLike>;
40
+ }[]): string[];
@@ -0,0 +1,145 @@
1
+ // What a fresh node's field starts with when its `sample` and `default` are silent (spec 2026-11-14 filled samples).
2
+ // Pure and synchronous, with no randomness: the site builds the picker's pictures and the admin the insert from the
3
+ // same schema, and equal input must give equal values or the picture and the insert differ. `nodeFromSample`
4
+ // (admin/tree-ops.ts) is the one caller that builds a node; nothing here reads a store.
5
+ import { optionsOf } from "./fields.js";
6
+ import { IMAGE_PLACEHOLDER } from "./media.js";
7
+ import { allowedOf } from "./toolset.js";
8
+ export const LOREM_TITLE = "Lorem ipsum dolor sit";
9
+ export const LOREM_SENTENCE = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.";
10
+ const LOREM_SECOND = "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";
11
+ const LOREM_POINTS = ["Duis aute irure dolor", "Excepteur sint occaecat", "Sunt in culpa qui officia"];
12
+ /** How many references or list items a filled field shows when nothing says otherwise. */
13
+ const FILL_COUNT = 3;
14
+ /** How many entries of a collection the picks hold: the site's read and the admin's operation ask for this many. */
15
+ export const SAMPLE_PICK_LIMIT = FILL_COUNT;
16
+ /** A key that reads as a title, in any casing or as part of the key (`cardTitle`, `buttonLabel`). */
17
+ const TITLE_LIKE = /title|heading|headline|name|label|eyebrow|kicker|button|cta/i;
18
+ const count = (v) => (typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : undefined);
19
+ /** Cut at a word boundary, without the punctuation the cut leaves hanging; a limit below one word cuts the word. */
20
+ function fitted(text, max) {
21
+ if (max === undefined || text.length <= max)
22
+ return text;
23
+ if (max < 1)
24
+ return undefined;
25
+ const cut = text.slice(0, max);
26
+ // The cut ended a word exactly when the next character is a space.
27
+ const whole = text[max] === " " ? cut : cut.slice(0, Math.max(0, cut.lastIndexOf(" ")));
28
+ const trimmed = whole.replace(/[\s,.;:]+$/, "");
29
+ return trimmed !== "" ? trimmed : cut;
30
+ }
31
+ const textNode = (text) => ({ type: "text", text });
32
+ const paragraph = (text) => ({ type: "paragraph", content: [textNode(text)] });
33
+ /** A TipTap document as the admin's editor stores one, from the formats the toolset allows: with a block format
34
+ * (a heading or a list) a heading, two paragraphs and a three-item list; with inline marks only, one paragraph. */
35
+ function richtextFill(toolset) {
36
+ const allowed = allowedOf(toolset);
37
+ const level = allowed.headingLevels.length > 0 ? Math.min(...allowed.headingLevels) : undefined;
38
+ const list = allowed.nodes.has("bulletList") ? "bulletList" : allowed.nodes.has("orderedList") ? "orderedList" : undefined;
39
+ if (level === undefined && list === undefined)
40
+ return { type: "doc", content: [paragraph(LOREM_SENTENCE)] };
41
+ return {
42
+ type: "doc",
43
+ content: [
44
+ ...(level !== undefined ? [{ type: "heading", attrs: { level }, content: [textNode(LOREM_TITLE)] }] : []),
45
+ paragraph(LOREM_SENTENCE),
46
+ paragraph(LOREM_SECOND),
47
+ ...(list !== undefined ? [{ type: list, content: LOREM_POINTS.map((p) => ({ type: "listItem", content: [paragraph(p)] })) }] : []),
48
+ ],
49
+ };
50
+ }
51
+ /** The collection a reference field targets: a function on a registered schema, a name on a serialized one. */
52
+ function targetName(d) {
53
+ const t = d.target;
54
+ if (typeof t === "string")
55
+ return t !== "" ? t : undefined;
56
+ if (typeof t !== "function")
57
+ return undefined;
58
+ try {
59
+ const name = t()?.name;
60
+ return typeof name === "string" && name !== "" ? name : undefined;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ /** A required choice shows its first option (FieldWidget renders no blank one), so a new node stores it too, and
67
+ * publish never says Required for a choice the editor sees made. */
68
+ export const firstChoice = (d) => (d.type === "select" || d.type === "radio") && d.optional !== true ? optionsOf(d.options)[0]?.value : undefined;
69
+ /** What is not content: a field's default, a required choice's first option, a boolean's false. */
70
+ export const starting = (d) => d.default ?? firstChoice(d) ?? (d.type === "boolean" ? false : undefined);
71
+ const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
72
+ /** An object's members: each one's starting value, else its generated one; a member with neither is left out. */
73
+ function members(d, options) {
74
+ const out = {};
75
+ for (const [key, sub] of Object.entries(subfields(d))) {
76
+ const v = starting(sub) ?? generated(key, sub, options);
77
+ if (v !== undefined)
78
+ out[key] = v;
79
+ }
80
+ return out;
81
+ }
82
+ /** The value for a field its sample and default leave out, or undefined to leave it as today. Text, richtext, a list
83
+ * and an object are generated when required only; an image whether required or not; a list of references whether
84
+ * required or not, a single reference when required. Every call builds new objects. */
85
+ export function generated(key, d, options = {}) {
86
+ const required = d.optional !== true;
87
+ const max = count(d.max);
88
+ switch (d.type) {
89
+ case "text":
90
+ return required ? fitted(TITLE_LIKE.test(key) ? LOREM_TITLE : LOREM_SENTENCE, max) : undefined;
91
+ case "richtext":
92
+ return required ? richtextFill(d.toolset) : undefined;
93
+ case "image":
94
+ return options.images === false ? undefined : { ...IMAGE_PLACEHOLDER };
95
+ case "ref": {
96
+ if (!required)
97
+ return undefined;
98
+ const target = targetName(d);
99
+ return target !== undefined ? options.picks?.[target]?.[0] : undefined;
100
+ }
101
+ case "refList": {
102
+ const target = targetName(d);
103
+ const ids = (target !== undefined ? options.picks?.[target] : undefined) ?? [];
104
+ const picked = ids.slice(0, Math.max(0, Math.min(FILL_COUNT, max ?? FILL_COUNT)));
105
+ return picked.length > 0 ? picked : undefined;
106
+ }
107
+ case "list": {
108
+ if (!required || !d.item || typeof d.item !== "object")
109
+ return undefined;
110
+ const item = d.item;
111
+ const min = count(d.min);
112
+ const n = Math.min(min !== undefined && min > 0 ? min : FILL_COUNT, max ?? Infinity);
113
+ if (n < 1)
114
+ return undefined;
115
+ const items = [];
116
+ for (let i = 0; i < n; i++) {
117
+ // An item is always there, so its own `optional` says nothing; a plain item takes the list's key for its size.
118
+ const v = item.type === "object" ? members(item, options) : generated(key, { ...item, optional: false }, options);
119
+ if (v === undefined)
120
+ return undefined;
121
+ items.push(v);
122
+ }
123
+ return items;
124
+ }
125
+ case "object":
126
+ return required ? members(d, options) : undefined;
127
+ default:
128
+ return undefined;
129
+ }
130
+ }
131
+ /** The collections a top-level `ref` or `refList` field of these sections targets, unique, in first-seen order: what
132
+ * the caller reads picks for (the resolver follows references at a node's top level only). */
133
+ export function sampleCollections(sections) {
134
+ const out = [];
135
+ for (const s of sections) {
136
+ for (const d of Object.values(s.fields)) {
137
+ if (d.type !== "ref" && d.type !== "refList")
138
+ continue;
139
+ const target = targetName(d);
140
+ if (target !== undefined && !out.includes(target))
141
+ out.push(target);
142
+ }
143
+ }
144
+ return out;
145
+ }
@@ -0,0 +1,30 @@
1
+ import type { AdminSection } from "./admin/serialize.ts";
2
+ import type { PageTree } from "./page-context.ts";
3
+ import type { PathTarget } from "./paths.ts";
4
+ import type { ContentReads, SiteHit } from "./site.ts";
5
+ import { type SamplePicks } from "./sample-fill.ts";
6
+ import type { ZonePolicy } from "./zones.ts";
7
+ /** What the tree is built from: a section's name, descriptors and sample (a registered schema or the admin's
8
+ * serialized one: `nodeFromSample` reads the same keys of both), and each global item's name and section. */
9
+ export type SampleSource = {
10
+ sections: Pick<AdminSection, "name" | "fields" | "settings" | "sample">[];
11
+ globals: {
12
+ name: string;
13
+ section?: string;
14
+ }[];
15
+ };
16
+ /** A sample node's id, fixed so the admin finds it without asking. The middle word keeps a section a developer
17
+ * named `custom` (or `global-…`) apart from the Custom section and from a global item. */
18
+ export declare function sampleNodeId(kind: "section" | "custom" | "global", name?: string): string;
19
+ /** The collections a zone's offered sections reference (spec 2026-11-14 filled samples): what the picks are read for. */
20
+ export declare const zoneSampleCollections: (source: SampleSource, policy: ZonePolicy | undefined) => string[];
21
+ /** The zone's picker rows as nodes, in the picker's order: the sections its policy allows, the Custom section where
22
+ * the zone takes one, the visual global items whose section it allows. Each is what an insert would create; a
23
+ * placement names its item alone (the service finds the row, and drops an item that has none). No other zone is
24
+ * in the tree. `picks` fill the sections' reference fields (spec 2026-11-14 filled samples); the tree is always the
25
+ * filled one, whatever the picker's switch says. */
26
+ export declare function sampleTree(source: SampleSource, policy: ZonePolicy | undefined, zone: string, picks?: SamplePicks): PageTree;
27
+ /** What a samples address draws for a hit: the page with the zone's samples resolved by the service. A page the
28
+ * service cannot answer for (no record yet, an older service, an outage) is drawn with its zones empty, so the
29
+ * admin finds no node and every row keeps its icon; an entry has no zones and is returned as it is. */
30
+ export declare function samplesHit(site: ContentReads, hit: SiteHit, target: PathTarget | null, locale: string, path: string, zone: string): Promise<SiteHit>;
@@ -0,0 +1,76 @@
1
+ // The picker's samples (spec 2026-11-14 add section previews): what a zone's "+" offers, as nodes the site can draw.
2
+ // The admin's modal shows each row as the site itself renders it; the site builds this tree from its registry (the
3
+ // service holds no samples), the service resolves it in place of the page's draft (ContentReads.loadSamples), and
4
+ // the admin finds each row's node in the frame by the ids below. Framework-free: both adapters use it.
5
+ import { allowsCustom } from "./admin/custom-section.js";
6
+ import { nodeFromSample } from "./admin/tree-ops.js";
7
+ import { CUSTOM_NODE_TYPE, defaultRow } from "./custom/index.js";
8
+ import { GLOBAL_NODE_TYPE } from "./global.js";
9
+ import { SAMPLE_PICK_LIMIT, sampleCollections } from "./sample-fill.js";
10
+ /** A sample node's id, fixed so the admin finds it without asking. The middle word keeps a section a developer
11
+ * named `custom` (or `global-…`) apart from the Custom section and from a global item. */
12
+ export function sampleNodeId(kind, name) {
13
+ return kind === "custom" ? "sample-custom" : `sample-${kind}-${name}`;
14
+ }
15
+ /** The sections a zone's picker offers: none for a locked zone or one the page type does not have. */
16
+ function offered(source, policy) {
17
+ if (!policy || policy.kind === "locked")
18
+ return [];
19
+ return source.sections.filter((s) => policy.allow === "*" || policy.allow.includes(s.name));
20
+ }
21
+ /** The collections a zone's offered sections reference (spec 2026-11-14 filled samples): what the picks are read for. */
22
+ export const zoneSampleCollections = (source, policy) => sampleCollections(offered(source, policy));
23
+ /** The zone's picker rows as nodes, in the picker's order: the sections its policy allows, the Custom section where
24
+ * the zone takes one, the visual global items whose section it allows. Each is what an insert would create; a
25
+ * placement names its item alone (the service finds the row, and drops an item that has none). No other zone is
26
+ * in the tree. `picks` fill the sections' reference fields (spec 2026-11-14 filled samples); the tree is always the
27
+ * filled one, whatever the picker's switch says. */
28
+ export function sampleTree(source, policy, zone, picks) {
29
+ const nodes = [];
30
+ if (policy && policy.kind !== "locked") {
31
+ const allows = (section) => policy.allow === "*" || policy.allow.includes(section);
32
+ for (const s of offered(source, policy))
33
+ nodes.push(nodeFromSample(s, sampleNodeId("section", s.name), { picks }));
34
+ // As the admin's customNode: the registry's custom sample is one default row.
35
+ if (allowsCustom(policy))
36
+ nodes.push({ id: sampleNodeId("custom"), type: CUSTOM_NODE_TYPE, fields: { rows: [defaultRow()] } });
37
+ for (const item of source.globals) {
38
+ if (item.section !== undefined && allows(item.section))
39
+ nodes.push({ id: sampleNodeId("global", item.name), type: GLOBAL_NODE_TYPE, fields: { item: item.name } });
40
+ }
41
+ }
42
+ return { zones: { [zone]: nodes } };
43
+ }
44
+ /** The reference picks of a zone's samples (spec 2026-11-14 filled samples): the first published entries of each
45
+ * collection its sections reference, one published read per collection in the page's locale: the read the admin's
46
+ * `entries.samplePicks` makes for the insert. A read that fails leaves that collection's references unfilled. */
47
+ async function samplePicks(site, template, zone, locale) {
48
+ const picks = {};
49
+ await Promise.all(site.sampleCollections(template, zone).map(async (collection) => {
50
+ try {
51
+ picks[collection] = (await site.getEntries(collection, { locale, limit: SAMPLE_PICK_LIMIT })).map((e) => e.id);
52
+ }
53
+ catch (e) {
54
+ console.error(`smoodly: the picker's sample entries of "${collection}" could not be read — ${e instanceof Error ? e.message : String(e)}`);
55
+ }
56
+ }));
57
+ return picks;
58
+ }
59
+ /** What a samples address draws for a hit: the page with the zone's samples resolved by the service. A page the
60
+ * service cannot answer for (no record yet, an older service, an outage) is drawn with its zones empty, so the
61
+ * admin finds no node and every row keeps its icon; an entry has no zones and is returned as it is. */
62
+ export async function samplesHit(site, hit, target, locale, path, zone) {
63
+ if (hit.kind !== "page")
64
+ return hit;
65
+ const emptied = { ...hit, tree: { ...hit.tree, zones: {} } };
66
+ if (!target)
67
+ return emptied;
68
+ try {
69
+ const resolved = await site.loadSamples(target, locale, path, site.sampleTree(hit.template, zone, await samplePicks(site, hit.template, zone, locale)));
70
+ return resolved?.kind === "page" ? resolved : emptied;
71
+ }
72
+ catch (e) {
73
+ console.error(`smoodly: the picker's samples could not be loaded — ${e instanceof Error ? e.message : String(e)}`);
74
+ return emptied;
75
+ }
76
+ }