smoodly 0.0.28 → 0.0.30

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 (98) hide show
  1. package/README.md +123 -17
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/CanvasWarning.d.ts +5 -0
  8. package/dist/admin/editor/CanvasWarning.js +10 -0
  9. package/dist/admin/editor/EditorView.js +113 -26
  10. package/dist/admin/editor/InlineRichtext.js +3 -1
  11. package/dist/admin/editor/Outline.d.ts +7 -0
  12. package/dist/admin/editor/Outline.js +8 -5
  13. package/dist/admin/editor/PageInspector.d.ts +6 -1
  14. package/dist/admin/editor/PageInspector.js +4 -2
  15. package/dist/admin/editor/PageSettings.js +26 -16
  16. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  17. package/dist/admin/editor/UnknownNode.js +8 -0
  18. package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
  19. package/dist/admin/editor/canvas-diagnosis.js +106 -0
  20. package/dist/admin/editor/crash-copy.d.ts +7 -0
  21. package/dist/admin/editor/crash-copy.js +13 -0
  22. package/dist/admin/editor/protocol.d.ts +2 -0
  23. package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
  24. package/dist/admin/editor/useCanvasBridge.js +14 -7
  25. package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
  26. package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
  27. package/dist/admin/field-paths.d.ts +3 -0
  28. package/dist/admin/field-paths.js +26 -0
  29. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  30. package/dist/admin/forms/OrphanFields.js +11 -0
  31. package/dist/admin/forms/RefPicker.js +6 -2
  32. package/dist/admin/forms/RichtextElementCard.js +9 -3
  33. package/dist/admin/forms/RichtextWidget.js +3 -1
  34. package/dist/admin/forms/TagsField.js +1 -1
  35. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  36. package/dist/admin/forms/richtext-form-context.js +5 -0
  37. package/dist/admin/health.d.ts +23 -0
  38. package/dist/admin/health.js +42 -0
  39. package/dist/admin/index.d.ts +1 -0
  40. package/dist/admin/next/bridge.js +4 -2
  41. package/dist/admin/next/index.d.ts +0 -1
  42. package/dist/admin/next/index.js +6 -1
  43. package/dist/admin/ops-impl.js +40 -9
  44. package/dist/admin/ops.d.ts +13 -0
  45. package/dist/admin/shell/AddRedirectModal.js +1 -1
  46. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  47. package/dist/admin/shell/ContentHealthPage.js +37 -0
  48. package/dist/admin/shell/EntriesList.js +4 -4
  49. package/dist/admin/shell/EntryForm.js +20 -3
  50. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  51. package/dist/admin/shell/ErrorPane.js +5 -4
  52. package/dist/admin/shell/GlobalForm.js +22 -2
  53. package/dist/admin/shell/PagesList.js +3 -3
  54. package/dist/admin/shell/SettingsView.js +6 -2
  55. package/dist/admin/shell/health-list.d.ts +27 -0
  56. package/dist/admin/shell/health-list.js +47 -0
  57. package/dist/admin/shell/settings.d.ts +4 -0
  58. package/dist/admin/shell/settings.js +5 -2
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/ActionMenu.d.ts +20 -0
  62. package/dist/admin/ui/ActionMenu.js +33 -7
  63. package/dist/admin/ui/Combobox.d.ts +8 -2
  64. package/dist/admin/ui/Combobox.js +48 -16
  65. package/dist/admin/ui/ConfirmDialog.js +3 -3
  66. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  67. package/dist/admin/ui/DriftNotice.js +10 -0
  68. package/dist/admin/ui/combo-box.d.ts +13 -0
  69. package/dist/admin/ui/combo-box.js +8 -0
  70. package/dist/admin/ui/confirm.d.ts +8 -0
  71. package/dist/admin/ui/confirm.js +17 -4
  72. package/dist/admin/ui/theme.js +29 -3
  73. package/dist/admin/validate.js +15 -47
  74. package/dist/custom/CustomSection.d.ts +1 -0
  75. package/dist/custom/CustomSection.js +4 -1
  76. package/dist/entry-store.d.ts +13 -0
  77. package/dist/entry-store.js +26 -3
  78. package/dist/index.d.ts +2 -2
  79. package/dist/marks.d.ts +12 -0
  80. package/dist/marks.js +14 -0
  81. package/dist/next/canvas-boundary.d.ts +16 -0
  82. package/dist/next/canvas-boundary.js +25 -0
  83. package/dist/next/page-renderer.d.ts +3 -1
  84. package/dist/next/page-renderer.js +33 -4
  85. package/dist/pairing.js +10 -3
  86. package/dist/render.d.ts +12 -1
  87. package/dist/render.js +15 -4
  88. package/dist/section-crash.d.ts +11 -0
  89. package/dist/section-crash.js +16 -0
  90. package/dist/section-guard.d.ts +27 -0
  91. package/dist/section-guard.js +41 -0
  92. package/dist/store.d.ts +9 -0
  93. package/dist/store.js +8 -0
  94. package/dist/supabase-entry-store.d.ts +2 -1
  95. package/dist/supabase-entry-store.js +27 -5
  96. package/dist/supabase-store.d.ts +2 -1
  97. package/dist/supabase-store.js +27 -0
  98. package/package.json +1 -1
@@ -5,7 +5,6 @@
5
5
  // A field whose `.hidden()` condition holds is skipped entirely — a hidden
6
6
  // required field is not required (spec 2026-09-14 §12); the same isHidden
7
7
  // the form uses decides, over the same scope.
8
- import { optionValues } from "../fields.js";
9
8
  import { isHidden } from "../conditions.js";
10
9
  import { cssProblem } from "../css.js";
11
10
  import { normalizeTag, sameTag } from "../tags.js";
@@ -13,6 +12,7 @@ import { hasContent } from "./richtext-doc.js";
13
12
  import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
14
13
  import { rowsProblems } from "../custom/rows.js";
15
14
  import { isCalendarDay, isInstant } from "./forms/date-picker.js";
15
+ import { fits, misfitMessage } from "./drift.js";
16
16
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
17
17
  const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
18
18
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -32,6 +32,11 @@ function validateValue(path, value, d, errors, elements, scope) {
32
32
  errors.push({ field: path, message: "Required." });
33
33
  return;
34
34
  }
35
+ // The one type check (spec 2026-09-27 editor safety §2): drift and publish share it.
36
+ if (!fits(d, value)) {
37
+ errors.push({ field: path, message: misfitMessage(d, value) });
38
+ return;
39
+ }
35
40
  if (d.type === "richtext") {
36
41
  if (!hasContent(value)) {
37
42
  if (!mayBeAbsent(d))
@@ -58,26 +63,20 @@ function validateValue(path, value, d, errors, elements, scope) {
58
63
  }
59
64
  // Dates (spec 2026-09-25 date pickers §2): the stored formats exactly, and a day the calendar has.
60
65
  if (d.type === "date") {
61
- if (typeof value !== "string" || !isCalendarDay(value))
66
+ if (!isCalendarDay(value))
62
67
  errors.push({ field: path, message: "Not a date (2026-09-02)." });
63
68
  return;
64
69
  }
65
70
  if (d.type === "datetime") {
66
- if (typeof value !== "string" || !isInstant(value))
71
+ if (!isInstant(value))
67
72
  errors.push({ field: path, message: "Not a date and time." });
68
73
  return;
69
74
  }
70
- if (d.type === "image" || d.type === "video" || d.type === "file") {
71
- if (typeof value !== "object" || Array.isArray(value) || typeof value.asset !== "string") {
72
- errors.push({ field: path, message: "Must be an uploaded file." });
73
- }
75
+ if (d.type === "image" || d.type === "video" || d.type === "file")
74
76
  return;
75
- }
76
77
  if (d.type === "list") {
77
- if (!Array.isArray(value)) {
78
- errors.push({ field: path, message: "Must be a list." });
78
+ if (!Array.isArray(value))
79
79
  return;
80
- }
81
80
  if (typeof d.min === "number" && value.length < d.min)
82
81
  errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
83
82
  if (typeof d.max === "number" && value.length > d.max)
@@ -89,37 +88,19 @@ function validateValue(path, value, d, errors, elements, scope) {
89
88
  return;
90
89
  }
91
90
  if (d.type === "object") {
92
- if (typeof value !== "object" || Array.isArray(value)) {
93
- errors.push({ field: path, message: "Must be an object." });
94
- return;
95
- }
96
91
  validateMap(path, value, subfields(d), errors, elements, { ...scope, self: value });
97
92
  return;
98
93
  }
99
- if (d.type === "boolean") {
100
- if (typeof value !== "boolean")
101
- errors.push({ field: path, message: "Must be on or off." });
102
- return;
103
- }
104
- if (d.type === "select" || d.type === "radio") {
105
- const allowed = optionValues(d.options);
106
- if (allowed.length > 0 && !allowed.includes(value)) {
107
- errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
108
- }
94
+ // The shape of these is fits' (above); what is left is the count.
95
+ if (d.type === "boolean" || d.type === "select" || d.type === "radio" || d.type === "checkboxes")
109
96
  return;
110
- }
111
97
  // Refs (spec 2026-09-17 §3): the shape and the count. Whether a target exists
112
98
  // and is published is publish integrity's question (store.ts), not this one's.
113
- if (d.type === "ref") {
114
- if (typeof value !== "string")
115
- errors.push({ field: path, message: "Must be an entry." });
99
+ if (d.type === "ref")
116
100
  return;
117
- }
118
101
  if (d.type === "refList") {
119
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || v === "")) {
120
- errors.push({ field: path, message: "Must be a list of entries." });
102
+ if (!Array.isArray(value))
121
103
  return;
122
- }
123
104
  if (typeof d.min === "number" && value.length < d.min)
124
105
  errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
125
106
  if (typeof d.max === "number" && value.length > d.max)
@@ -130,10 +111,8 @@ function validateValue(path, value, d, errors, elements, scope) {
130
111
  return;
131
112
  }
132
113
  if (d.type === "tags") {
133
- if (!Array.isArray(value)) {
134
- errors.push({ field: path, message: "Must be a list of tags." });
114
+ if (!Array.isArray(value))
135
115
  return;
136
- }
137
116
  if (typeof d.min === "number" && value.length < d.min)
138
117
  errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
139
118
  if (typeof d.max === "number" && value.length > d.max)
@@ -146,17 +125,6 @@ function validateValue(path, value, d, errors, elements, scope) {
146
125
  });
147
126
  return;
148
127
  }
149
- if (d.type === "checkboxes") {
150
- if (!Array.isArray(value)) {
151
- errors.push({ field: path, message: "Must be a list." });
152
- return;
153
- }
154
- const allowed = optionValues(d.options);
155
- if (value.some((v) => !allowed.includes(v))) {
156
- errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
157
- }
158
- return;
159
- }
160
128
  if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
161
129
  errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
162
130
  }
@@ -19,6 +19,7 @@ export type CustomSectionProps = {
19
19
  export type CustomSectionComponent = ((props: CustomSectionProps) => ReactElement) & {
20
20
  schema: {
21
21
  name: string;
22
+ title: string;
22
23
  };
23
24
  };
24
25
  export declare function createCustomSection({ RichText: Bound, breakpoint, elements }: CustomOptions & {
@@ -4,6 +4,7 @@ import { RichText } from "../richtext-render.js";
4
4
  import { FIELD_ATTR, ITEM_ATTR } from "../marks.js";
5
5
  import { CUSTOM_NODE_TYPE, DEFAULT_BREAKPOINT } from "./schema.js";
6
6
  import { customCss } from "./css.js";
7
+ import { RENDER_VIEW } from "../section-guard.js";
7
8
  const isRow = (r) => typeof r === "object" && r !== null && Array.isArray(r.columns);
8
9
  export function createCustomSection({ RichText: Bound, breakpoint = DEFAULT_BREAKPOINT, elements = [] }) {
9
10
  const Body = Bound ?? (({ doc }) => _jsx(RichText, { doc: doc, elements: elements }));
@@ -15,6 +16,8 @@ export function createCustomSection({ RichText: Bound, breakpoint = DEFAULT_BREA
15
16
  : styles;
16
17
  return (_jsx(Section, { styles: withLayout, "data-custom": "", children: list.map((row, i) => (_jsx("div", { className: row.reverse ? "smd-row smd-row--reverse" : "smd-row", "data-row": i, [ITEM_ATTR]: `rows.${i}`, children: row.columns.map((doc, j) => (_jsx("div", { className: "smd-col", [FIELD_ATTR]: `columns.${j}`, children: _jsx(Body, { doc: doc }) }, j))) }, i))) }));
17
18
  }
18
- CustomSection.schema = { name: CUSTOM_NODE_TYPE };
19
+ CustomSection.schema = { name: CUSTOM_NODE_TYPE, title: "Custom section" };
20
+ // Smoodly's own plain view: the guard may call it (spec 2026-09-27 section guard §1).
21
+ CustomSection[RENDER_VIEW] = CustomSection;
19
22
  return CustomSection;
20
23
  }
@@ -40,6 +40,12 @@ export type EntryVersion = {
40
40
  /** When the content last changed: the unpublished draft row is rewritten in place (spec 2026-09-13 history §1). */
41
41
  savedAt: number;
42
42
  };
43
+ /** One entry row for the drift scan (spec 2026-09-27 editor safety §3), whatever its collection:
44
+ * locale → the live version's merged fields, null where the locale is not published. */
45
+ export type EntryScan = {
46
+ record: EntryRecord;
47
+ live: Record<string, Record<string, unknown> | null>;
48
+ };
43
49
  export type EntryUsage = {
44
50
  sourceKind: string;
45
51
  sourceId: string;
@@ -96,6 +102,8 @@ export interface EntryStore {
96
102
  create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
97
103
  get(collection: string, id: string): Promise<EntryRecord | null>;
98
104
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
105
+ /** Every entry row, registered collection or not, with each published locale's live fields (spec 2026-09-27 editor safety §3). */
106
+ scanEntries(): Promise<EntryScan[]>;
99
107
  /** Batched read for the resolver: the MERGED fields in `locale`, keyed
100
108
  * by id. An id that is missing, absent in the locale, or (with
101
109
  * `status`) unpublished there is omitted. With `{ status: "published" }`
@@ -203,6 +211,10 @@ export type SplitFields = {
203
211
  * Every key that is neither localized nor `slug` is shared — declared or
204
212
  * not — so a stray key never disappears into a locale row. */
205
213
  export declare function splitFields(fields: Record<string, unknown>, schema: CollectionSchema<any>): SplitFields;
214
+ /** A localized key's value still in the shared fields — saved while the field was shared — stays
215
+ * there as every locale's fallback: a write never deletes it (spec 2026-09-27 editor safety §7).
216
+ * Reads merge shared, then the row, so each locale shows its own value once it has one. */
217
+ export declare function keepLocalizedShared(before: Record<string, unknown>, shared: Record<string, unknown>, schema: CollectionSchema<any>): Record<string, unknown>;
206
218
  export declare function entryLocaleRow(record: EntryRecord, locale: string): EntryLocale;
207
219
  /** The merged view of one locale: shared + that locale's fields + its
208
220
  * slug. A fresh object; throws for an absent locale. */
@@ -285,6 +297,7 @@ export declare class MemoryEntryStore implements EntryStore {
285
297
  discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
286
298
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
287
299
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
300
+ scanEntries(): Promise<EntryScan[]>;
288
301
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
289
302
  restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
290
303
  getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
@@ -98,6 +98,17 @@ export function splitFields(fields, schema) {
98
98
  }
99
99
  return { shared: structuredClone(shared), localized: structuredClone(localized), slug: slugOf(fields) };
100
100
  }
101
+ /** A localized key's value still in the shared fields — saved while the field was shared — stays
102
+ * there as every locale's fallback: a write never deletes it (spec 2026-09-27 editor safety §7).
103
+ * Reads merge shared, then the row, so each locale shows its own value once it has one. */
104
+ export function keepLocalizedShared(before, shared, schema) {
105
+ const out = { ...shared };
106
+ for (const key of localizedKeys(schema)) {
107
+ if (key in before && !(key in out))
108
+ out[key] = structuredClone(before[key]);
109
+ }
110
+ return out;
111
+ }
101
112
  export function entryLocaleRow(record, locale) {
102
113
  const row = record.locales[locale];
103
114
  if (!row)
@@ -408,7 +419,7 @@ export class MemoryEntryStore {
408
419
  throw entryErrors.noSlug(collection);
409
420
  this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
410
421
  const before = { fields: entry.fields, row: { ...row } };
411
- entry.fields = shared;
422
+ entry.fields = keepLocalizedShared(before.fields, shared, schema);
412
423
  row.fields = localized;
413
424
  row.slug = slug;
414
425
  try {
@@ -449,7 +460,7 @@ export class MemoryEntryStore {
449
460
  if (!snapshot)
450
461
  throw entryErrors.nothingPublished(collection, id, locale);
451
462
  const { shared, localized } = splitFields(snapshot.fields, schema);
452
- entry.fields = shared;
463
+ entry.fields = keepLocalizedShared(entry.fields, shared, schema);
453
464
  row.fields = localized;
454
465
  const abandoned = row.draftVersionId;
455
466
  row.draftVersionId = row.publishedVersionId;
@@ -517,6 +528,18 @@ export class MemoryEntryStore {
517
528
  this.writeRefs(entry);
518
529
  return this.snapshot(entry);
519
530
  }
531
+ async scanEntries() {
532
+ return [...this.entries.values()].map((entry) => ({
533
+ record: this.snapshot(entry),
534
+ live: Object.fromEntries(Object.entries(entry.locales).map(([l, row]) => {
535
+ if (row.status !== "published")
536
+ return [l, null];
537
+ // A legacy published row without a snapshot serves its live row (spec 2026-09-14 §1).
538
+ const snap = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
539
+ return [l, snap ? structuredClone(snap.fields) : entryFieldsIn(entry, l)];
540
+ })),
541
+ }));
542
+ }
520
543
  async listVersions(collection, id, locale) {
521
544
  this.must(collection, id);
522
545
  return [...this.versions.values()]
@@ -535,7 +558,7 @@ export class MemoryEntryStore {
535
558
  if (versionId === row.publishedVersionId)
536
559
  return this.discardDraft(collection, id, locale, by, expected);
537
560
  const { shared, localized } = splitFields(version.fields, schema);
538
- entry.fields = shared;
561
+ entry.fields = keepLocalizedShared(entry.fields, shared, schema);
539
562
  row.fields = localized; // the slug is the row's own and never versioned
540
563
  this.recordVersion(entry, locale);
541
564
  this.bump(entry, row, by);
package/dist/index.d.ts CHANGED
@@ -51,7 +51,7 @@ export type { PathIndex } from "./path-index.ts";
51
51
  export { MemoryEntryStore } from "./entry-store.ts";
52
52
  export { SupabaseEntryStore } from "./supabase-entry-store.ts";
53
53
  export { SupabasePathIndex } from "./supabase-path-index.ts";
54
- export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, Where, WhereValue, } from "./entry-store.ts";
54
+ export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryScan, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, Where, WhereValue, } from "./entry-store.ts";
55
55
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.ts";
56
56
  export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.ts";
57
57
  export type { AffectedTargets } from "./revalidate.ts";
@@ -60,7 +60,7 @@ export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve
60
60
  export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
61
61
  export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
62
62
  export { fixedPathFor } from "./localize.ts";
63
- export type { PageStore, PageRecord, PageLocale, PageVersion, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
63
+ export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
64
64
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
65
65
  export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
66
66
  export type { FieldBuilder, Descriptor, ValueOf, Option, OptionInput, OptionValue } from "./fields.ts";
package/dist/marks.d.ts CHANGED
@@ -53,4 +53,16 @@ export type Scope<T> = Marks<T> & {
53
53
  export declare function marks<S extends {
54
54
  __f?: BuilderMap;
55
55
  }>(_schema: S): Marks<NamespaceValues<NonNullable<S["__f"]>>>;
56
+ /** A crashed section's placeholder (spec 2026-09-27 section guard §5): `section` — the guard caught
57
+ * it and the live site leaves it out; `page` — only the canvas net caught it, the live page would fail. */
58
+ export declare const CRASHED_ATTR = "data-smd-crashed";
59
+ /** One crashed section as the bridge reports it. */
60
+ export type CrashedSection = {
61
+ id: string;
62
+ live: "left-out" | "page-fails";
63
+ };
64
+ /** The crashed sections among the marked elements, in document order — what the bridge posts. */
65
+ export declare function crashedOf(els: ReadonlyArray<{
66
+ getAttribute(name: string): string | null;
67
+ }>): CrashedSection[];
56
68
  export {};
package/dist/marks.js CHANGED
@@ -44,3 +44,17 @@ function level() {
44
44
  export function marks(_schema) {
45
45
  return level();
46
46
  }
47
+ /** A crashed section's placeholder (spec 2026-09-27 section guard §5): `section` — the guard caught
48
+ * it and the live site leaves it out; `page` — only the canvas net caught it, the live page would fail. */
49
+ export const CRASHED_ATTR = "data-smd-crashed";
50
+ /** The crashed sections among the marked elements, in document order — what the bridge posts. */
51
+ export function crashedOf(els) {
52
+ const out = [];
53
+ for (const el of els) {
54
+ const id = el.getAttribute(NODE_ATTR);
55
+ const mark = el.getAttribute(CRASHED_ATTR);
56
+ if (id && (mark === "section" || mark === "page"))
57
+ out.push({ id, live: mark === "section" ? "left-out" : "page-fails" });
58
+ }
59
+ return out;
60
+ }
@@ -0,0 +1,16 @@
1
+ import { Component, type ReactNode } from "react";
2
+ type Props = {
3
+ id: string;
4
+ title: string;
5
+ children?: ReactNode;
6
+ };
7
+ type State = {
8
+ failed: boolean;
9
+ error: unknown;
10
+ };
11
+ export declare class CanvasBoundary extends Component<Props, State> {
12
+ state: State;
13
+ static getDerivedStateFromError(error: unknown): State;
14
+ render(): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
15
+ }
16
+ export {};
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The Next adapter's canvas net (spec 2026-09-27 section guard §5): in
4
+ // editor mode each section sits in <Suspense> and this boundary, so a
5
+ // throw the guard cannot catch — in a component the section renders, or
6
+ // in a 'use client' section — fails only that section: Server Components
7
+ // hand it to the browser to retry, and this catches it there. Never
8
+ // rendered outside editor mode. Imports nothing from `next`, but it is
9
+ // Next's mechanism, so it lives with the adapter.
10
+ import { Component } from "react";
11
+ import { CrashPlaceholder } from "../section-crash.js";
12
+ export class CanvasBoundary extends Component {
13
+ constructor() {
14
+ super(...arguments);
15
+ this.state = { failed: false, error: undefined };
16
+ }
17
+ static getDerivedStateFromError(error) {
18
+ return { failed: true, error };
19
+ }
20
+ render() {
21
+ return this.state.failed
22
+ ? _jsx(CrashPlaceholder, { id: this.props.id, title: this.props.title, error: this.state.error, live: "page" })
23
+ : this.props.children;
24
+ }
25
+ }
@@ -1,9 +1,11 @@
1
- import type { ComponentType, ReactElement } from "react";
1
+ import { type ComponentType, type ReactElement } from "react";
2
2
  import type { Metadata } from "next";
3
3
  import type { Descriptor } from "../fields.ts";
4
4
  import { type PairedPage } from "../render.tsx";
5
5
  import type { GlobalSchema } from "../global.ts";
6
6
  import { type SmoodlySite } from "../site.ts";
7
+ /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
8
+ export declare const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it \u2014 a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
7
9
  /** A registered section, as renderPage's registry needs it. */
8
10
  type SectionEntry = ComponentType<any> & {
9
11
  schema: {
@@ -1,15 +1,29 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The site-route seam (spec 2026-09-05 §5): the two route files call
3
+ // renderSmoodlyPath; everything else — request gating, draft mode, the
4
+ // cache keyed by (locale, path) and tagged by id, 404s, the editor
5
+ // bridge — lives here, over the framework-free site layer. Bound once by
6
+ // the app, exactly like createAdminOpHandler.
7
+ import { Suspense } from "react";
2
8
  import { unstable_cache } from "next/cache";
3
9
  import { draftMode } from "next/headers";
4
- import { notFound, permanentRedirect } from "next/navigation";
10
+ import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
5
11
  import { connection } from "next/server";
6
12
  import { EditorBridge } from "../admin/next/bridge.js";
13
+ import { CanvasBoundary } from "./canvas-boundary.js";
14
+ import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
7
15
  import { fixedPathFor } from "../localize.js";
8
16
  import { joinPath } from "../paths.js";
9
17
  import { renderPage } from "../render.js";
10
18
  import { entryTag, pageTag, globalTag } from "../revalidate.js";
11
19
  import { pageContextOf } from "../site.js";
12
20
  import { metadataFrom } from "./meta.js";
21
+ /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
22
+ export const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it — a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
23
+ /** The canvas net (spec 2026-09-27 section guard §5): Server Components hand a section that failed to the
24
+ * browser, where CanvasBoundary catches it. Defined here, not in the client file: a function exported
25
+ * from a "use client" module is a reference on the server and cannot be called. */
26
+ const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasBoundary, { id: section.id, title: section.title, children: child }) }));
13
27
  export function createPageRenderer(options) {
14
28
  const { site } = options;
15
29
  const href = options.href ?? ((path) => path);
@@ -48,7 +62,7 @@ export function createPageRenderer(options) {
48
62
  notFound();
49
63
  }
50
64
  const page = pageContextOf(hit, href);
51
- return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page });
65
+ return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page, rethrow: unstable_rethrow, editorWrap: canvasWrap });
52
66
  }
53
67
  const View = entryPages.get(hit.collection);
54
68
  if (!View) {
@@ -67,10 +81,22 @@ export function createPageRenderer(options) {
67
81
  languages[locale] = href(alt.path, locale);
68
82
  return { alternates: { languages } };
69
83
  }
84
+ // Next's cacheComponents serves the catch-all from a fallback shell that
85
+ // commits 200 before the page renders, so 404s and 308s are lost (spec
86
+ // 2026-09-27 cache components). The variable is Next's own, inlined at
87
+ // build: a warning, never an error, and once per renderer.
88
+ let cacheComponentsWarned = false;
89
+ function warnCacheComponents() {
90
+ if (cacheComponentsWarned || String(process.env.__NEXT_CACHE_COMPONENTS) !== "true")
91
+ return;
92
+ cacheComponentsWarned = true;
93
+ console.warn(CACHE_COMPONENTS_WARNING);
94
+ }
70
95
  async function page(pathFor, opts, accept) {
96
+ warnCacheComponents(); // before connection(), so a build's prerender prints it too
71
97
  // A CMS route is request-rendered by construction: without this a
72
98
  // route with no dynamic segment would be prerendered against the
73
- // database at build time (DESIGN.md §3, OQ #19).
99
+ // database at build time (DESIGN.md §3).
74
100
  await connection();
75
101
  const locale = opts?.locale ?? site.locales.default;
76
102
  const path = pathFor(locale);
@@ -86,7 +112,10 @@ export function createPageRenderer(options) {
86
112
  const hit = target ? await load(target, locale, path, draft, version) : null;
87
113
  if (!hit || !accept(hit))
88
114
  notFound();
89
- return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
115
+ // The draft marker tells the page editor's canvas diagnosis that Smoodly
116
+ // rendered this response (spec 2026-09-27 shadowed-page detector §3);
117
+ // React hoists the meta into the head. Never outside draft mode.
118
+ return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
90
119
  }
91
120
  async function metadata(pathFor, opts, accept) {
92
121
  // generateMetadata runs alongside the page render, so it gates too.
package/dist/pairing.js CHANGED
@@ -1,14 +1,21 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { defaultsOf } from "./field-defaults.js";
3
+ import { isPlainView, RENDER_VIEW } from "./section-guard.js";
3
4
  function pair(schema, View) {
4
5
  const defaults = defaultsOf(schema.settings);
5
- const Component = (props) => {
6
+ const viewProps = (props) => {
6
7
  const { settings: overrides, styles, ...fields } = props ?? {};
7
- const settings = { ...defaults, ...overrides };
8
+ return { ...fields, settings: { ...defaults, ...overrides }, styles: styles ?? {} };
9
+ };
10
+ const Component = (props) => {
8
11
  const V = View;
9
- return _jsx(V, { ...fields, settings: settings, styles: styles ?? {} });
12
+ return _jsx(V, { ...viewProps(props) });
10
13
  };
11
14
  Component.schema = schema;
15
+ // The guard's direct call (spec 2026-09-27 section guard §1): only a plain-function view may be called.
16
+ if (isPlainView(View)) {
17
+ Component[RENDER_VIEW] = (props) => View(viewProps(props));
18
+ }
12
19
  return Component;
13
20
  }
14
21
  export function section(schema, View) {
package/dist/render.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ComponentType, ReactElement } from "react";
1
+ import { type ComponentType, type ReactElement, type ReactNode } from "react";
2
2
  import type { PageAlternate } from "./paths.ts";
3
3
  import type { PageSchema } from "./page.ts";
4
4
  import type { BuilderMap, NamespaceValues } from "./schema.ts";
@@ -70,8 +70,14 @@ export declare const EMPTY_PAGE_CONTEXT: PageContext;
70
70
  type AnyPaired = ComponentType<any> & {
71
71
  schema: {
72
72
  name: string;
73
+ title?: string;
73
74
  };
74
75
  };
76
+ /** The adapter's canvas net (spec 2026-09-27 section guard §5): called per section, in editor mode only. */
77
+ export type EditorWrap = (child: ReactNode, section: {
78
+ id: string;
79
+ title: string;
80
+ }) => ReactNode;
75
81
  export type ZoneContent = {
76
82
  readonly name: string;
77
83
  readonly nodes: TreeNode[];
@@ -79,6 +85,9 @@ export type ZoneContent = {
79
85
  /** The config's CSS values behind the styles set (spec 2026-09-14 §2). */
80
86
  readonly styleValues: StyleValues;
81
87
  readonly editor: boolean;
88
+ /** Control flow the guard must let through — the adapter's (Next: unstable_rethrow; spec 2026-09-27 section guard §2). */
89
+ readonly rethrow?: (error: unknown) => void;
90
+ readonly editorWrap?: EditorWrap;
82
91
  };
83
92
  export declare function Zone({ of: content }: {
84
93
  of: ZoneContent;
@@ -102,5 +111,7 @@ export type Registry = {
102
111
  export declare function renderPage<Zones extends Record<string, unknown>>(Page: PairedPage<Zones>, tree: PageTree, registry: Registry, options?: {
103
112
  editor?: boolean;
104
113
  page?: PageContext;
114
+ rethrow?: (error: unknown) => void;
115
+ editorWrap?: EditorWrap;
105
116
  }): ReactElement;
106
117
  export {};
package/dist/render.js CHANGED
@@ -1,8 +1,11 @@
1
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { EMPTY_ATTR, ZONE_ATTR } from "./marks.js";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Fragment } from "react";
3
+ import { EMPTY_ATTR, NODE_ATTR, ZONE_ATTR } from "./marks.js";
3
4
  import { SMD_KEY } from "./section-wrapper.js";
4
5
  import { DEFAULT_STYLE_VALUES } from "./styles.js";
5
6
  import { CUSTOM_NODE_TYPE, createCustomSection } from "./custom/index.js";
7
+ import { renderGuarded } from "./section-guard.js";
8
+ import { PLACEHOLDER_STYLE } from "./section-crash.js";
6
9
  /** The `page` prop of a view rendered outside the site (a test, a preview
7
10
  * of a shape): a real object, so a view never guards an optional prop, but
8
11
  * no position in the tree. Frozen: one singleton shared by every such
@@ -23,12 +26,20 @@ export function Zone({ of: content }) {
23
26
  return (_jsx(_Fragment, { children: nodes.map((node) => {
24
27
  const Component = content.components.get(node.type);
25
28
  if (!Component) {
29
+ // Editor mode draws a box the bridge can measure, so the node is selectable in place
30
+ // (spec 2026-09-27 editor safety §5); the site renders nothing, as before.
31
+ if (content.editor) {
32
+ return (_jsxs("div", { [NODE_ATTR]: node.id, style: PLACEHOLDER_STYLE, children: ["Unknown section `", node.type, "`"] }, node.id));
33
+ }
26
34
  console.warn(`smoodly: no registered component for node type "${node.type}" — skipping.`);
27
35
  return null;
28
36
  }
29
37
  // The node id, the css and the values ride the styles under one reserved key; the wrapper reads and strips it.
30
38
  const styles = { ...(node.styles ?? {}), [SMD_KEY]: { node: node.id, css: typeof node.css === "string" ? node.css : undefined, values: content.styleValues } };
31
- return _jsx(Component, { ...node.fields, settings: node.settings ?? {}, styles: styles }, node.id);
39
+ // Through the guard (spec 2026-09-27 section guard): a throw in the section's own code leaves it out, not the page.
40
+ const title = Component.schema.title ?? node.type;
41
+ const section = renderGuarded(Component, { ...node.fields, settings: node.settings ?? {}, styles }, { id: node.id, type: node.type, title, editor: content.editor, rethrow: content.rethrow });
42
+ return (_jsx(Fragment, { children: content.editor && content.editorWrap ? content.editorWrap(section, { id: node.id, title }) : section }, node.id));
32
43
  }) }));
33
44
  }
34
45
  export function page(schema, View) {
@@ -49,7 +60,7 @@ export function renderPage(Page, tree, registry, options) {
49
60
  const styleValues = registry.styles ?? DEFAULT_STYLE_VALUES;
50
61
  const zones = Object.fromEntries(Object.keys(Page.schema.zones).map((zone) => [
51
62
  zone,
52
- { name: zone, nodes: tree.zones?.[zone] ?? [], components, styleValues, editor: options?.editor ?? false },
63
+ { name: zone, nodes: tree.zones?.[zone] ?? [], components, styleValues, editor: options?.editor ?? false, rethrow: options?.rethrow, editorWrap: options?.editorWrap },
53
64
  ]));
54
65
  return _jsx(Page, { zones: zones, page: (options?.page ?? EMPTY_PAGE_CONTEXT) });
55
66
  }
@@ -0,0 +1,11 @@
1
+ import type { CSSProperties } from "react";
2
+ /** The canvas placeholders' look — the unknown section's too (spec 2026-09-27 editor safety §5). */
3
+ export declare const PLACEHOLDER_STYLE: CSSProperties;
4
+ /** Why it crashed, for the placeholder: the message, or the digest when a production build stripped the message. */
5
+ export declare function crashMessage(error: unknown): string;
6
+ export declare function CrashPlaceholder({ id, title, error, live }: {
7
+ id: string;
8
+ title: string;
9
+ error: unknown;
10
+ live: "section" | "page";
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { CRASHED_ATTR, NODE_ATTR } from "./marks.js";
3
+ /** The canvas placeholders' look — the unknown section's too (spec 2026-09-27 editor safety §5). */
4
+ export const PLACEHOLDER_STYLE = {
5
+ padding: "24px 16px", border: "1px dashed #b54708", color: "#b54708", font: "500 14px/1.4 system-ui, sans-serif", textAlign: "center",
6
+ };
7
+ /** Why it crashed, for the placeholder: the message, or the digest when a production build stripped the message. */
8
+ export function crashMessage(error) {
9
+ const digest = typeof error === "object" && error !== null ? error.digest : undefined;
10
+ if (typeof digest === "string")
11
+ return `the server log has the error (digest ${digest})`;
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+ export function CrashPlaceholder({ id, title, error, live }) {
15
+ return (_jsx("div", { [NODE_ATTR]: id, [CRASHED_ATTR]: live, style: PLACEHOLDER_STYLE, children: `Section “${title}” failed to render — ${crashMessage(error)}` }));
16
+ }
@@ -0,0 +1,27 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ /** The paired component's own view call, set by pair() and the custom section — only for a plain-function view. */
3
+ export declare const RENDER_VIEW: unique symbol;
4
+ /** A view the guard may call: a plain function — not a client reference or memo/forwardRef ($$typeof), not a class. */
5
+ export declare function isPlainView(View: unknown): boolean;
6
+ export type GuardContext = {
7
+ id: string;
8
+ type: string;
9
+ title: string;
10
+ editor: boolean;
11
+ rethrow?: (error: unknown) => void;
12
+ };
13
+ type Settled = {
14
+ ok: true;
15
+ value: unknown;
16
+ } | {
17
+ ok: false;
18
+ error: unknown;
19
+ };
20
+ /** A section through the guard when its component offers the view call; any other component as an element, unguarded. */
21
+ export declare function renderGuarded(Component: ComponentType<any>, props: Record<string, unknown>, ctx: GuardContext): ReactNode;
22
+ /** An async section, awaited in place — no <Suspense>, so it renders where it always did. */
23
+ export declare function SectionGuard({ settled, ctx }: {
24
+ settled: Promise<Settled>;
25
+ ctx: GuardContext;
26
+ }): Promise<ReactNode>;
27
+ export {};