smoodly 0.0.27 → 0.0.29

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 (96) hide show
  1. package/README.md +75 -3
  2. package/dist/admin/client-ops.js +2 -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/EditorView.js +70 -15
  8. package/dist/admin/editor/InlineRichtext.js +3 -1
  9. package/dist/admin/editor/Outline.d.ts +7 -0
  10. package/dist/admin/editor/Outline.js +8 -5
  11. package/dist/admin/editor/PageInspector.d.ts +9 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.d.ts +4 -1
  14. package/dist/admin/editor/PageSettings.js +33 -15
  15. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  16. package/dist/admin/editor/UnknownNode.js +8 -0
  17. package/dist/admin/field-paths.d.ts +3 -0
  18. package/dist/admin/field-paths.js +26 -0
  19. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  20. package/dist/admin/forms/OrphanFields.js +11 -0
  21. package/dist/admin/forms/RefPicker.js +6 -2
  22. package/dist/admin/forms/RichtextElementCard.js +9 -3
  23. package/dist/admin/forms/RichtextWidget.js +3 -1
  24. package/dist/admin/forms/TagsField.js +1 -1
  25. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  26. package/dist/admin/forms/richtext-form-context.js +5 -0
  27. package/dist/admin/health.d.ts +23 -0
  28. package/dist/admin/health.js +42 -0
  29. package/dist/admin/ops-impl.js +124 -14
  30. package/dist/admin/ops.d.ts +53 -1
  31. package/dist/admin/redirect-path.d.ts +6 -0
  32. package/dist/admin/redirect-path.js +33 -0
  33. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  34. package/dist/admin/shell/AddRedirectModal.js +29 -0
  35. package/dist/admin/shell/AdminApp.js +2 -3
  36. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  37. package/dist/admin/shell/ContentHealthPage.js +37 -0
  38. package/dist/admin/shell/EntriesList.js +4 -4
  39. package/dist/admin/shell/EntryForm.js +32 -7
  40. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  41. package/dist/admin/shell/ErrorPane.js +5 -4
  42. package/dist/admin/shell/GlobalForm.js +22 -2
  43. package/dist/admin/shell/IconRail.d.ts +1 -1
  44. package/dist/admin/shell/IconRail.js +3 -2
  45. package/dist/admin/shell/PagesList.js +9 -7
  46. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  47. package/dist/admin/shell/RedirectsPage.js +75 -0
  48. package/dist/admin/shell/SettingsView.d.ts +4 -2
  49. package/dist/admin/shell/SettingsView.js +22 -14
  50. package/dist/admin/shell/health-list.d.ts +27 -0
  51. package/dist/admin/shell/health-list.js +47 -0
  52. package/dist/admin/shell/live-url.d.ts +10 -2
  53. package/dist/admin/shell/live-url.js +9 -4
  54. package/dist/admin/shell/members-list.js +1 -1
  55. package/dist/admin/shell/redirects-list.d.ts +12 -0
  56. package/dist/admin/shell/redirects-list.js +15 -0
  57. package/dist/admin/shell/settings.d.ts +16 -3
  58. package/dist/admin/shell/settings.js +16 -9
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/Combobox.d.ts +8 -2
  62. package/dist/admin/ui/Combobox.js +48 -16
  63. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  64. package/dist/admin/ui/ConfirmDialog.js +5 -4
  65. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  66. package/dist/admin/ui/DriftNotice.js +10 -0
  67. package/dist/admin/ui/combo-box.d.ts +13 -0
  68. package/dist/admin/ui/combo-box.js +8 -0
  69. package/dist/admin/ui/confirm.d.ts +21 -2
  70. package/dist/admin/ui/confirm.js +26 -9
  71. package/dist/admin/ui/theme.js +32 -3
  72. package/dist/admin/validate.js +15 -47
  73. package/dist/entry-store.d.ts +31 -6
  74. package/dist/entry-store.js +39 -11
  75. package/dist/index.d.ts +2 -2
  76. package/dist/next/page-renderer.js +28 -9
  77. package/dist/path-index.d.ts +28 -4
  78. package/dist/path-index.js +37 -4
  79. package/dist/paths.d.ts +16 -0
  80. package/dist/paths.js +9 -0
  81. package/dist/render.js +7 -2
  82. package/dist/schema-version.d.ts +1 -1
  83. package/dist/schema-version.js +1 -1
  84. package/dist/site.d.ts +4 -1
  85. package/dist/site.js +3 -1
  86. package/dist/sql.js +19 -1
  87. package/dist/store.d.ts +39 -5
  88. package/dist/store.js +32 -8
  89. package/dist/supabase-entry-store.d.ts +9 -3
  90. package/dist/supabase-entry-store.js +39 -13
  91. package/dist/supabase-path-index.d.ts +10 -3
  92. package/dist/supabase-path-index.js +50 -17
  93. package/dist/supabase-store.d.ts +15 -3
  94. package/dist/supabase-store.js +42 -8
  95. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  96. package/package.json +1 -1
@@ -671,6 +671,13 @@ body:has(.sm-admin){margin:0}
671
671
  .sm-cell--title{font-weight:500}
672
672
  .sm-cell--grip{overflow:visible;display:flex;align-items:center;margin-left:-4px}
673
673
  .sm-empty{padding:28px 8px;color:var(--text-muted)}
674
+ /* Settings › Content health (spec 2026-09-27 editor safety §4): headed sections of grouped rows. */
675
+ .sm-health section+section{margin-top:24px}
676
+ .sm-health__section{font-size:var(--text-sm);font-weight:600;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform);color:var(--text-muted);margin:0 0 8px;padding:0 8px}
677
+ .sm-health__group{display:flex;flex-direction:column;gap:2px;margin-bottom:16px}
678
+ .sm-health__group > .sm-btn{align-self:flex-start;margin-top:4px}
679
+ .sm-health__headline{display:flex;justify-content:space-between;gap:12px;font-weight:600;padding:6px 8px;margin:0}
680
+ .sm-health__row{grid-template-columns:minmax(160px,1fr) 140px 90px 120px 70px}
674
681
  /* ── drag and drop (spec 2026-09-09 §7) ─────────────────────────────── */
675
682
  /* The grip is faint until its row is hovered or it has focus. */
676
683
  .sm-grip{
@@ -795,6 +802,12 @@ body:has(.sm-admin){margin:0}
795
802
  font-family:var(--font-sans);font-size:var(--text-xs);font-weight:600;letter-spacing:var(--tracking-tag);text-transform:uppercase;color:var(--text-muted);
796
803
  }
797
804
  .sm-stale{padding:8px 20px 0}
805
+ /* Drift (spec 2026-09-27 editor safety §5): the document notice under the stale line, the orphan group at a form's foot. */
806
+ .sm-drift{padding:8px 20px 0}
807
+ .sm-orphans{margin-top:16px;border-top:1px solid var(--line);padding-top:12px}
808
+ .sm-orphans__head{cursor:pointer;font-size:var(--text-sm);font-weight:600;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform);color:var(--text-muted)}
809
+ .sm-orphans__row{display:grid;grid-template-columns:minmax(80px,auto) 1fr auto auto;gap:8px;align-items:center;margin-top:8px}
810
+ .sm-orphans__value{color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:var(--text-sm)}
798
811
  .sm-lock{display:inline-flex;align-items:center;margin-left:6px;color:var(--text-muted);vertical-align:middle}
799
812
  .sm-presence{margin:0;padding:6px 20px 0;font-size:13px;color:var(--text-muted)}
800
813
  .sm-banner{
@@ -922,6 +935,14 @@ body:has(.sm-admin){margin:0}
922
935
  .sm-outline-row__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
923
936
  .sm-outline-row__note{font-size:var(--text-sm);font-weight:500;flex:none;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform)}
924
937
  .sm-outline-row__add{color:var(--accent);font-weight:500;flex:none}
938
+ /* Drift marks (spec 2026-09-27 editor safety §5): a stripe inside the row's left edge, red for broken, muted for left over. */
939
+ .sm-outline-row--broken{box-shadow:inset 3px 0 0 var(--danger)}
940
+ .sm-outline-row--orphan{box-shadow:inset 3px 0 0 var(--line-dashed)}
941
+ .sm-outline-note{margin:0;font-size:var(--text-sm);color:var(--danger)}
942
+ /* A removed zone's one row wraps its sentence rather than cutting it: the inspector is narrow. */
943
+ .sm-outline-row--removed{height:auto;min-height:var(--outline-row-h);padding-block:6px}
944
+ .sm-outline-row--removed .sm-outline-row__label{white-space:normal}
945
+ .sm-rtcard--broken{box-shadow:inset 3px 0 0 var(--danger)}
925
946
  /* A row with a grip: the grip takes the left padding's place and the label follows it. */
926
947
  .sm-outline-row--grip{padding-left:2px}
927
948
  .sm-outline-row--grip .sm-outline-row__label{flex:1}
@@ -943,12 +964,17 @@ body:has(.sm-admin){margin:0}
943
964
  width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
944
965
  color:var(--accent);display:grid;place-items:center;font-size:13px;
945
966
  }
946
- /* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it. */
967
+ /* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it —
968
+ portaled and fixed at the input's width (--combo-w), above modals and below
969
+ the confirm scrim (2026-09-27); inline under the input in static markup. */
947
970
  .sm-combo{position:relative}
948
971
  .sm-combo .sm-input{width:100%;padding-right:30px}
972
+ .sm-combo--suffix .sm-input{padding-right:96px}
973
+ .sm-combo__suffix{position:absolute;right:28px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:4px}
974
+ .sm-combo__suffix a.sm-iconbtn{text-decoration:none}
949
975
  .sm-combo__chevron{position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--text-muted)}
950
976
  .sm-combo__list{position:absolute;top:calc(100% + 4px);left:0;right:0;width:auto;max-height:280px;overflow-y:auto;z-index:60}
951
- .sm-combo__list--up{top:auto;bottom:calc(100% + 4px)}
977
+ .sm-combo__list--floating{position:fixed;top:auto;right:auto;width:var(--combo-w,300px);z-index:75}
952
978
  .sm-combo__empty{padding:8px 10px;color:var(--text-muted);font-size:var(--text-sm)}
953
979
  /* Reference picker (spec 2026-09-17 §2): the picked entries under the search input. */
954
980
  .sm-refpicker{display:flex;flex-direction:column;gap:6px;min-width:0}
@@ -978,7 +1004,7 @@ body:has(.sm-admin){margin:0}
978
1004
  .sm-combo--tag{width:180px}
979
1005
  .sm-combo--tag .sm-input{height:var(--control-h-sm);padding-right:10px}
980
1006
  .sm-combo--tag .sm-combo__chevron{display:none}
981
- .sm-combo--tag .sm-combo__list{right:auto;width:240px}
1007
+ .sm-combo__list.sm-combo__list--tag{right:auto;width:240px}
982
1008
  /* ── date and datetime pickers (spec 2026-09-25) ──────────────────────
983
1009
  The text box with a calendar button as its suffix; for datetime an hour
984
1010
  and a minute select beside it; the popover a month grid on the menu
@@ -1152,6 +1178,9 @@ body:has(.sm-admin){margin:0}
1152
1178
  }
1153
1179
  .sm-dialog__context strong{font-weight:500;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:var(--text-md)}
1154
1180
  .sm-dialog__context code{font-family:var(--font-mono);flex:none}
1181
+ .sm-confirm__option{margin-top:4px;font-size:var(--text-md);color:var(--text)}
1182
+ .sm-redirects-from{display:flex;flex-direction:column;gap:4px}
1183
+ .sm-redirects-from code{font-size:var(--text-sm)}
1155
1184
  /* A form dialog's fields: white fields on the white card get the tile tone instead. */
1156
1185
  .sm-dialog__form{display:flex;flex-direction:column;gap:14px}
1157
1186
  .sm-dialog__form .sm-input,.sm-dialog__form .sm-select,.sm-dialog__form .sm-inputwrap{background:var(--surface)}
@@ -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
  }
@@ -2,9 +2,9 @@ import type { CollectionSchema, EntryOrder } from "./collections.ts";
2
2
  import type { GlobalSchema } from "./global.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import { MemoryRefIndex } from "./ref-index.ts";
5
- import { type PathIndex } from "./path-index.ts";
5
+ import { type PathHit, type PathIndex } from "./path-index.ts";
6
6
  import { type DraftToken } from "./draft-token.ts";
7
- import { type LocaleSet, type PathTarget } from "./paths.ts";
7
+ import { type LocaleSet } from "./paths.ts";
8
8
  export type EntryLocale = {
9
9
  /** The entry's segment in this locale; null claims no URL. */
10
10
  slug: string | null;
@@ -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" }`
@@ -107,7 +115,10 @@ export interface EntryStore {
107
115
  }): Promise<Record<string, Record<string, unknown>>>;
108
116
  /** Replaces the merged fields for `locale`: shared keys on the node
109
117
  * (every locale sees them), localized keys and the slug on the row. */
110
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
118
+ /** `redirect` (default true) keeps a published row's old address as a redirect when the slug changes (spec 2026-09-26 redirects §1). */
119
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null, options?: {
120
+ redirect?: boolean;
121
+ }): Promise<EntryRecord>;
111
122
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
112
123
  /** The live row goes back to what is published: the published snapshot's fields
113
124
  * (shared and localized, split as on save — the slug is never versioned) written over
@@ -143,6 +154,8 @@ export interface EntryStore {
143
154
  move(collection: string, id: string, to: {
144
155
  parentId: string | null;
145
156
  index?: number;
157
+ }, options?: {
158
+ redirect?: boolean;
146
159
  }): Promise<EntryRecord>;
147
160
  /** Rewrite manual order to 1..N for the given ids, one batched pass. */
148
161
  reorder(collection: string, orderedIds: string[]): Promise<void>;
@@ -153,8 +166,10 @@ export interface EntryStore {
153
166
  sourceKind: string;
154
167
  sourceId: string;
155
168
  }[]>;
156
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
169
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
157
170
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
171
+ /** locale → this entry's old addresses. */
172
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
158
173
  }
159
174
  export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
160
175
  /** A global item is a collection with exactly one entry (spec 2026-09-07). */
@@ -196,6 +211,10 @@ export type SplitFields = {
196
211
  * Every key that is neither localized nor `slug` is shared — declared or
197
212
  * not — so a stray key never disappears into a locale row. */
198
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>;
199
218
  export declare function entryLocaleRow(record: EntryRecord, locale: string): EntryLocale;
200
219
  /** The merged view of one locale: shared + that locale's fields + its
201
220
  * slug. A fresh object; throws for an absent locale. */
@@ -271,17 +290,22 @@ export declare class MemoryEntryStore implements EntryStore {
271
290
  getMany(collection: string, ids: string[], locale: string, options?: {
272
291
  status?: "published";
273
292
  }): Promise<Record<string, Record<string, unknown>>>;
274
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
293
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null, options?: {
294
+ redirect?: boolean;
295
+ }): Promise<EntryRecord>;
275
296
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
276
297
  discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
277
298
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
278
299
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
300
+ scanEntries(): Promise<EntryScan[]>;
279
301
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
280
302
  restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
281
303
  getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
282
304
  move(collection: string, id: string, to: {
283
305
  parentId: string | null;
284
306
  index?: number;
307
+ }, options?: {
308
+ redirect?: boolean;
285
309
  }): Promise<EntryRecord>;
286
310
  delete(collection: string, id: string): Promise<void>;
287
311
  reorder(collection: string, orderedIds: string[]): Promise<void>;
@@ -290,6 +314,7 @@ export declare class MemoryEntryStore implements EntryStore {
290
314
  sourceKind: string;
291
315
  sourceId: string;
292
316
  }[]>;
293
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
317
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
294
318
  pathsOf(collection: string, id: string): Promise<{}>;
319
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
295
320
  }
@@ -13,10 +13,11 @@ import { collectionDepth, collectionOrder } from "./collections.js";
13
13
  import { collectEntryRefs } from "./refs.js";
14
14
  import { MemoryRefIndex } from "./ref-index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
+ import { groupRedirects } from "./store.js";
16
17
  import { assertFresh } from "./draft-token.js";
17
18
  import { globalEntryId } from "./global-id.js";
18
19
  import { DEFAULT_SPACE_ID } from "./sql-space.js";
19
- import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
20
+ import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, rewriteTargets, } from "./paths.js";
20
21
  /** The field types whose value is an array: a scalar `where` on one is
21
22
  * containment, and any-of on one is refused by the site layer (both
22
23
  * adapters read the text form of the whole array, never its elements). */
@@ -97,6 +98,17 @@ export function splitFields(fields, schema) {
97
98
  }
98
99
  return { shared: structuredClone(shared), localized: structuredClone(localized), slug: slugOf(fields) };
99
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
+ }
100
112
  export function entryLocaleRow(record, locale) {
101
113
  const row = record.locales[locale];
102
114
  if (!row)
@@ -270,8 +282,8 @@ export class MemoryEntryStore {
270
282
  }
271
283
  return sets;
272
284
  }
273
- async writePaths(collection, nodes) {
274
- await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
285
+ async writePaths(collection, nodes, redirect = true) {
286
+ await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
275
287
  }
276
288
  writeRefs(entry) {
277
289
  this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection), this.elements));
@@ -396,7 +408,7 @@ export class MemoryEntryStore {
396
408
  }
397
409
  return out;
398
410
  }
399
- async update(collection, id, locale, fields, by, expected) {
411
+ async update(collection, id, locale, fields, by, expected, options) {
400
412
  const schema = this.schema(collection);
401
413
  const entry = this.must(collection, id);
402
414
  const row = entryLocaleRow(entry, locale);
@@ -407,11 +419,11 @@ export class MemoryEntryStore {
407
419
  throw entryErrors.noSlug(collection);
408
420
  this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
409
421
  const before = { fields: entry.fields, row: { ...row } };
410
- entry.fields = shared;
422
+ entry.fields = keepLocalizedShared(before.fields, shared, schema);
411
423
  row.fields = localized;
412
424
  row.slug = slug;
413
425
  try {
414
- await this.writePaths(collection, this.subtree(entry));
426
+ await this.writePaths(collection, this.subtree(entry), options?.redirect ?? true);
415
427
  }
416
428
  catch (e) {
417
429
  entry.fields = before.fields;
@@ -448,7 +460,7 @@ export class MemoryEntryStore {
448
460
  if (!snapshot)
449
461
  throw entryErrors.nothingPublished(collection, id, locale);
450
462
  const { shared, localized } = splitFields(snapshot.fields, schema);
451
- entry.fields = shared;
463
+ entry.fields = keepLocalizedShared(entry.fields, shared, schema);
452
464
  row.fields = localized;
453
465
  const abandoned = row.draftVersionId;
454
466
  row.draftVersionId = row.publishedVersionId;
@@ -505,7 +517,7 @@ export class MemoryEntryStore {
505
517
  throw entryErrors.localeInUse(locale, blockers);
506
518
  delete entry.locales[locale];
507
519
  try {
508
- await this.writePaths(collection, [entry]);
520
+ await this.writePaths(collection, [entry], false);
509
521
  }
510
522
  catch (e) {
511
523
  entry.locales[locale] = row;
@@ -516,6 +528,18 @@ export class MemoryEntryStore {
516
528
  this.writeRefs(entry);
517
529
  return this.snapshot(entry);
518
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
+ }
519
543
  async listVersions(collection, id, locale) {
520
544
  this.must(collection, id);
521
545
  return [...this.versions.values()]
@@ -534,7 +558,7 @@ export class MemoryEntryStore {
534
558
  if (versionId === row.publishedVersionId)
535
559
  return this.discardDraft(collection, id, locale, by, expected);
536
560
  const { shared, localized } = splitFields(version.fields, schema);
537
- entry.fields = shared;
561
+ entry.fields = keepLocalizedShared(entry.fields, shared, schema);
538
562
  row.fields = localized; // the slug is the row's own and never versioned
539
563
  this.recordVersion(entry, locale);
540
564
  this.bump(entry, row, by);
@@ -546,7 +570,7 @@ export class MemoryEntryStore {
546
570
  const v = this.versions.get(versionId);
547
571
  return v ? structuredClone(v) : null;
548
572
  }
549
- async move(collection, id, to) {
573
+ async move(collection, id, to, options) {
550
574
  if (isGlobalSchema(this.schema(collection)))
551
575
  throw entryErrors.owned(collection, "it has no tree position");
552
576
  const entry = this.must(collection, id);
@@ -572,7 +596,7 @@ export class MemoryEntryStore {
572
596
  const previous = new Map(siblings.map((s) => [s.id, s.sort]));
573
597
  siblings.forEach((s, i) => (s.sort = i + 1));
574
598
  try {
575
- await this.writePaths(collection, subtree);
599
+ await this.writePaths(collection, subtree, options?.redirect ?? true);
576
600
  }
577
601
  catch (e) {
578
602
  Object.assign(entry, before);
@@ -621,4 +645,8 @@ export class MemoryEntryStore {
621
645
  pathsOf(collection, id) {
622
646
  return this.row(collection, id) ? this.paths.pathsOf({ kind: "entry", id }) : Promise.resolve({});
623
647
  }
648
+ async redirectsOf(collection, id) {
649
+ this.schema(collection);
650
+ return groupRedirects(await this.paths.redirectsOf({ kind: "entry", id }));
651
+ }
624
652
  }
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";
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { unstable_cache } from "next/cache";
3
3
  import { draftMode } from "next/headers";
4
- import { notFound } from "next/navigation";
4
+ import { notFound, permanentRedirect } from "next/navigation";
5
5
  import { connection } from "next/server";
6
6
  import { EditorBridge } from "../admin/next/bridge.js";
7
7
  import { fixedPathFor } from "../localize.js";
@@ -15,18 +15,31 @@ export function createPageRenderer(options) {
15
15
  const href = options.href ?? ((path) => path);
16
16
  const shapes = new Map(options.pages.filter((p) => p.schema.kind === "page").map((p) => [p.schema.name, p]));
17
17
  const entryPages = new Map(options.pages.filter((p) => p.schema.kind === "entryPage" && p.schema.collection).map((p) => [p.schema.collection, p]));
18
- /** locate (uncached: the id it returns IS the cache tag) then load —
19
- * cached per (locale, path) outside draft mode. */
20
- async function load(locale, path, draft, version) {
21
- const target = await site.locate(locale, path);
22
- if (!target)
23
- return null;
18
+ /** The hit for a located target (locate is uncached: the id it returns
19
+ * IS the cache tag): load — cached per (locale, path) outside draft mode. */
20
+ async function load(target, locale, path, draft, version) {
24
21
  if (draft)
25
22
  return site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) });
26
23
  return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path], {
27
24
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
28
25
  })();
29
26
  }
27
+ /** A redirect row (spec 2026-09-26 redirects §7): the node's CURRENT path in this locale, through href, the query kept; no path there is a 404. */
28
+ async function redirectTo(target, locale, params) {
29
+ const current = (await site.targetPaths(target))[locale];
30
+ if (!current)
31
+ notFound();
32
+ const query = new URLSearchParams();
33
+ for (const [k, v] of Object.entries(params ?? {})) {
34
+ if (typeof v === "string")
35
+ query.append(k, v);
36
+ else
37
+ for (const item of v ?? [])
38
+ query.append(k, item);
39
+ }
40
+ const qs = query.toString();
41
+ permanentRedirect(href(current, locale) + (qs ? `?${qs}` : ""));
42
+ }
30
43
  function render(hit, editor, locale) {
31
44
  if (hit.kind === "page") {
32
45
  const Page = shapes.get(hit.template);
@@ -67,7 +80,10 @@ export function createPageRenderer(options) {
67
80
  const editor = draft && params?.["smoodly-editor"] === "1";
68
81
  const versionParam = params?.["smoodly-version"];
69
82
  const version = draft && typeof versionParam === "string" && versionParam !== "" ? versionParam : undefined;
70
- const hit = await load(locale, path, draft, version);
83
+ const target = await site.locate(locale, path);
84
+ if (target?.redirect)
85
+ await redirectTo(target, locale, params);
86
+ const hit = target ? await load(target, locale, path, draft, version) : null;
71
87
  if (!hit || !accept(hit))
72
88
  notFound();
73
89
  return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
@@ -78,7 +94,10 @@ export function createPageRenderer(options) {
78
94
  const locale = opts?.locale ?? site.locales.default;
79
95
  const path = pathFor(locale);
80
96
  const { isEnabled: draft } = await draftMode();
81
- const hit = await load(locale, path, draft);
97
+ const target = await site.locate(locale, path);
98
+ if (!target || target.redirect)
99
+ return {};
100
+ const hit = await load(target, locale, path, draft);
82
101
  if (!hit || !accept(hit))
83
102
  return {};
84
103
  return { ...(hit.kind === "page" ? metadataFrom(hit.tree.meta, { title: hit.title }) : {}), ...alternates(hit) };
@@ -1,16 +1,40 @@
1
1
  import type { PathRow, PathTarget } from "./paths.ts";
2
2
  export type { PathRow, PathTarget } from "./paths.ts";
3
+ /** A rewrite target; `redirectLocales` names the locales whose old live rows stay as redirects (none = drop them). */
4
+ export type RewriteTarget = PathTarget & {
5
+ redirectLocales?: readonly string[];
6
+ };
7
+ /** What lives at an address: the target, and whether the row is a redirect to it. */
8
+ export type PathHit = PathTarget & {
9
+ redirect: boolean;
10
+ };
3
11
  export interface PathIndex {
4
- rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
12
+ rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
13
+ /** Every row of the targets, redirects included (a deleted node has no old addresses). */
5
14
  remove(targets: PathTarget[]): Promise<void>;
6
- resolve(locale: string, path: string): Promise<PathTarget | null>;
15
+ resolve(locale: string, path: string): Promise<PathHit | null>;
16
+ /** locale → the target's LIVE path. */
7
17
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
18
+ /** The target's redirect rows. */
19
+ redirectsOf(target: PathTarget): Promise<PathRow[]>;
20
+ listLive(locale: string): Promise<PathRow[]>;
21
+ listRedirects(locale: string): Promise<PathRow[]>;
22
+ /** A redirect by hand: refused (pathLiveError) at a live address; another redirect row there is replaced. */
23
+ addRedirect(row: PathRow): Promise<void>;
24
+ /** Deletes a redirect row; a live row at the address is left alone. */
25
+ removeRedirect(locale: string, path: string): Promise<void>;
8
26
  }
9
27
  export declare const pathTakenError: (locale: string, path: string) => Error;
28
+ export declare const pathLiveError: (locale: string, path: string) => Error;
10
29
  export declare class MemoryPathIndex implements PathIndex {
11
30
  private rows;
12
- rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
31
+ rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
13
32
  remove(targets: PathTarget[]): Promise<void>;
14
- resolve(locale: string, path: string): Promise<PathTarget | null>;
33
+ resolve(locale: string, path: string): Promise<PathHit | null>;
15
34
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
35
+ redirectsOf(target: PathTarget): Promise<PathRow[]>;
36
+ listLive(locale: string): Promise<PathRow[]>;
37
+ listRedirects(locale: string): Promise<PathRow[]>;
38
+ addRedirect(row: PathRow): Promise<void>;
39
+ removeRedirect(locale: string, path: string): Promise<void>;
16
40
  }