smoodly 0.0.54 → 0.0.56

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.
@@ -10,6 +10,7 @@ import { DatePicker } from "./DatePicker.js";
10
10
  import { optionsOf } from "../../fields.js";
11
11
  import { isHidden } from "../../conditions.js";
12
12
  import { cssProblem } from "../../css.js";
13
+ import { linkProblem } from "../validate.js";
13
14
  import { Button, Field, Switch } from "../ui/primitives.js";
14
15
  import { fieldLabel } from "../field-label.js";
15
16
  import { patchObject } from "./list.js";
@@ -22,6 +23,8 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
22
23
  const message = errors?.[path] ?? (below ? errors?.[below] : undefined);
23
24
  // The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
24
25
  const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
26
+ // A link the site would not render is marked as it is typed; publish refuses it with the same words.
27
+ const problem = d.type === "link" ? linkProblem(value) : null;
25
28
  // The custom section's rows (spec 2026-09-14 §6): the row cards instead of the generic list.
26
29
  if (d.widget === "rows") {
27
30
  const rows = _jsx(RowsWidget, { descriptor: d, value: value, onChange: onChange, errors: errors, path: path, inlinePath: inlinePath, onEditHere: onEditHere });
@@ -146,7 +149,7 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
146
149
  // A <label> activates its first labelable descendant, so it may only wrap a
147
150
  // single control — around refList's rows, an object's fieldset or a list's
148
151
  // rows it would turn every click into a press of the first button.
149
- return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, hint: hint, error: message, asDiv: asDiv, path: path, children: control }));
152
+ return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, hint: hint, error: message ?? problem ?? undefined, asDiv: asDiv, path: path, children: control }));
150
153
  }
151
154
  /** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
152
155
  export function InlineNote({ onEditHere }) {
@@ -1,5 +1,6 @@
1
1
  import { GLOBAL_NODE_TYPE } from "../global.js";
2
2
  import { setAtPath, unsetAtPath } from "./field-paths.js";
3
+ import { optionsOf } from "../fields.js";
3
4
  export function zoneOf(tree, id) {
4
5
  for (const [zone, nodes] of Object.entries(tree.zones)) {
5
6
  if (nodes.some((n) => n.id === id))
@@ -123,12 +124,15 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
123
124
  * broken-image icon or a stray zero, worse than nothing at all. With no
124
125
  * sample, leave the key out of `fields` entirely rather than seed a lie. */
125
126
  const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
127
+ /** A required choice shows its first option (FieldWidget renders no blank one), so a new node stores it too — as a
128
+ * list item does (forms/list.ts) — and publish never says Required for a choice the editor sees made. */
129
+ const firstChoice = (d) => (d.type === "select" || d.type === "radio") && d.optional !== true ? optionsOf(d.options)[0]?.value : undefined;
126
130
  /** Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
127
131
  export function nodeFromSample(section, id) {
128
132
  const fields = {};
129
133
  const settings = {};
130
134
  for (const [key, d] of Object.entries(section.fields)) {
131
- const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
135
+ const sampled = section.sample?.[key] ?? d.default ?? firstChoice(d) ?? (d.type === "boolean" ? false : undefined);
132
136
  if (sampled !== undefined)
133
137
  fields[key] = sampled;
134
138
  else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
@@ -140,7 +144,7 @@ export function nodeFromSample(section, id) {
140
144
  fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
141
145
  }
142
146
  for (const [key, d] of Object.entries(section.settings)) {
143
- const v = section.sample?.[key] ?? d.default;
147
+ const v = section.sample?.[key] ?? d.default ?? firstChoice(d);
144
148
  if (v !== undefined)
145
149
  settings[key] = v;
146
150
  }
@@ -830,6 +830,7 @@ body:has(.sm-admin){margin:0}
830
830
  .sm-outline-row__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
831
831
  .sm-outline-row__note{font-size:var(--text-sm);font-weight:500;flex:none;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform)}
832
832
  .sm-outline-row__add{color:var(--accent);font-weight:500;flex:none}
833
+ .sm-outline-row__menu{display:flex;flex:none}
833
834
  /* Drift marks (spec 2026-09-27 editor safety §5): a stripe inside the row's left edge, red for broken, muted for left over. */
834
835
  .sm-outline-row--broken{box-shadow:inset 3px 0 0 var(--danger)}
835
836
  .sm-outline-row--orphan{box-shadow:inset 3px 0 0 var(--line-dashed)}
@@ -886,18 +887,16 @@ body:has(.sm-admin){margin:0}
886
887
  .sm-structure__add{
887
888
  display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px 0 4px;border-radius:999px;border:0;
888
889
  background:var(--plus-bg);color:var(--plus-fg);font:500 var(--text-sm)/1 var(--font-sans);cursor:pointer;
889
- box-shadow:var(--plus-shadow);white-space:nowrap;opacity:.35;transition:opacity .12s,background .12s;
890
+ box-shadow:var(--plus-shadow);white-space:nowrap;transition:background .12s;
890
891
  }
891
892
  .sm-structure__add-icon{
892
893
  width:16px;height:16px;flex:none;border-radius:50%;background:var(--plus-icon-bg);color:var(--plus-icon-fg);
893
894
  display:grid;place-items:center;font-size:12px;line-height:1;
894
895
  }
895
- .sm-structure__row:hover .sm-structure__add,.sm-structure__slot:hover .sm-structure__add,.sm-structure__add:focus-visible,.sm-structure__empty .sm-structure__add{opacity:1}
896
896
  .sm-structure__add:hover{background:var(--plus-hover)}
897
- /* The lifted row keeps the hook's transform and z-index; its card carries the shadow, its add slot hides. */
897
+ /* The lifted row keeps the hook's transform and z-index; its card carries the shadow. */
898
898
  .sm-structure__row.sm-drag--lifted{background:transparent;box-shadow:none}
899
899
  .sm-structure__row.sm-drag--lifted .sm-structure__card{box-shadow:var(--shadow-pop)}
900
- .sm-structure__row.sm-drag--lifted .sm-structure__slot{visibility:hidden}
901
900
  .sm-structure__empty{
902
901
  display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:var(--outline-row-h);padding:4px 12px;
903
902
  border:1px dashed var(--line-dashed);border-radius:var(--outline-row-radius);color:var(--text-muted);font-weight:500;
@@ -19,6 +19,10 @@ export type ValidationScope = {
19
19
  fields?: Record<string, unknown>;
20
20
  settings?: Record<string, unknown>;
21
21
  };
22
+ /** A link field takes only what the site renders (richtext-href.ts's rule): the widget says so as the editor types,
23
+ * publish refuses it (register 2026-09-26, the content-surface sweep). */
24
+ export declare const LINK_MESSAGE = "A link starts with https://, http://, mailto:, tel:, / for a page on this site, # or ?.";
25
+ export declare const linkProblem: (value: unknown) => string | null;
22
26
  /** The element schemas an element node is validated against (spec 2026-09-08 block nodes §2). */
23
27
  export type ElementDescs = Record<string, {
24
28
  fields: Record<string, Desc>;
@@ -14,9 +14,14 @@ import { rowsProblems } from "../custom/rows.js";
14
14
  import { isCalendarDay, isInstant } from "./forms/date-picker.js";
15
15
  import { fits, misfitMessage } from "./drift.js";
16
16
  import { isSegment } from "../paths.js";
17
+ import { safeHref } from "../richtext-href.js";
17
18
  import { isCrop } from "../media.js";
18
19
  /** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
19
20
  export const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
21
+ /** A link field takes only what the site renders (richtext-href.ts's rule): the widget says so as the editor types,
22
+ * publish refuses it (register 2026-09-26, the content-surface sweep). */
23
+ export const LINK_MESSAGE = "A link starts with https://, http://, mailto:, tel:, / for a page on this site, # or ?.";
24
+ export const linkProblem = (value) => typeof value === "string" && value.trim() !== "" && safeHref(value) === null ? LINK_MESSAGE : null;
20
25
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
21
26
  const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
22
27
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -47,6 +52,10 @@ function validateValue(path, value, d, errors, elements, scope) {
47
52
  errors.push({ field: path, message: SLUG_GRAMMAR_MESSAGE });
48
53
  return;
49
54
  }
55
+ if (d.type === "link" && linkProblem(value)) {
56
+ errors.push({ field: path, message: LINK_MESSAGE });
57
+ return;
58
+ }
50
59
  if (d.type === "richtext") {
51
60
  if (!hasContent(value)) {
52
61
  if (!mayBeAbsent(d))
@@ -19,5 +19,6 @@ const chunks = richtextChunks(doc);
19
19
  // An own name only: `constructor` or `toString` must not find Object's members.
20
20
  const View = Object.prototype.hasOwnProperty.call(views.elements, chunk.name) ? views.elements[chunk.name] : undefined;
21
21
  if (!View) { console.warn(`smoodly: no src/elements/${chunk.name}.astro for element "${chunk.name}" — skipping.`); return null; }
22
- return <View {...chunk.fields} settings={{ ...info.element(chunk.name).defaults, ...chunk.settings }} />;
22
+ // `styles={{}}` as the React pairing hands it (pairing.tsx: `styles ?? {}`), so a view that reads styles never throws here.
23
+ return <View {...chunk.fields} settings={{ ...info.element(chunk.name).defaults, ...chunk.settings }} styles={{}} />;
23
24
  })}
@@ -1,8 +1,11 @@
1
1
  export declare const DRAFT_COOKIE = "smoodly-draft";
2
2
  export declare const PREVIEW_BASE = "/_smoodly/preview";
3
- export declare function draftCookieValue(secret: string): Promise<string>;
4
- /** `verify` compares in constant time. */
5
- export declare function isDraftCookie(value: string | undefined, secret: string): Promise<boolean>;
3
+ /** Seconds: the browser's Max-Age and the signed expiry agree. */
4
+ export declare const DRAFT_COOKIE_MAX_AGE: number;
5
+ /** `2.<expiry ms>.<hex signature>`; `now` is for tests. */
6
+ export declare function draftCookieValue(secret: string, now?: number): Promise<string>;
7
+ /** `verify` compares in constant time; an expired value is refused before the key is touched. */
8
+ export declare function isDraftCookie(value: string | undefined, secret: string, now?: number): Promise<boolean>;
6
9
  /** The page path a preview address names ("/" for the base itself), or null when `pathname` is not one. */
7
10
  export declare function previewPath(pathname: string): string | null;
8
11
  /** The page path a preview address may rewrite to: never the admin, never a preview address again (a
@@ -1,25 +1,38 @@
1
1
  // The preview address and its key (DESIGN.md Vision, preview per framework; spec 2026-10-02 astro cloud
2
2
  // fit §7): Astro serves every draft under PREVIEW_BASE, to a browser that holds the draft cookie. The
3
- // cookie's value is an HMAC of a fixed message under the site's secret key: the draft operation, which
4
- // only a signed-in editor can call, is the only thing that can make one. Web Crypto only, so the code
5
- // runs on any host.
3
+ // cookie's value is an HMAC under the site's secret key over a message carrying its expiry: the draft
4
+ // operation, which only a signed-in editor can call, is the only thing that can make one, and a value
5
+ // ends twelve hours after it was made, with the browser's Max-Age (register 2026-10-05: before, one
6
+ // fixed value served every editor for as long as the key stood). Web Crypto only, so the code runs on
7
+ // any host.
6
8
  export const DRAFT_COOKIE = "smoodly-draft";
7
9
  export const PREVIEW_BASE = "/_smoodly/preview";
10
+ /** Seconds: the browser's Max-Age and the signed expiry agree. */
11
+ export const DRAFT_COOKIE_MAX_AGE = 12 * 3600;
8
12
  const encoder = new TextEncoder();
9
- const MESSAGE = encoder.encode("smoodly-draft:v1");
13
+ const VERSION = "2";
14
+ const message = (expiry) => encoder.encode(`smoodly-draft:v${VERSION}:${expiry}`);
10
15
  const keyOf = (secret) => crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
11
- export async function draftCookieValue(secret) {
16
+ /** `2.<expiry ms>.<hex signature>`; `now` is for tests. */
17
+ export async function draftCookieValue(secret, now = Date.now()) {
12
18
  if (!secret)
13
19
  throw new Error("smoodly: the draft cookie needs SMOODLY_SECRET_KEY.");
14
- const signature = await crypto.subtle.sign("HMAC", await keyOf(secret), MESSAGE);
15
- return [...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, "0")).join("");
20
+ const expiry = now + DRAFT_COOKIE_MAX_AGE * 1000;
21
+ const signature = await crypto.subtle.sign("HMAC", await keyOf(secret), message(expiry));
22
+ return `${VERSION}.${expiry}.${[...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
16
23
  }
17
- /** `verify` compares in constant time. */
18
- export async function isDraftCookie(value, secret) {
19
- if (!secret || !value || !/^[0-9a-f]{64}$/.test(value))
24
+ /** `verify` compares in constant time; an expired value is refused before the key is touched. */
25
+ export async function isDraftCookie(value, secret, now = Date.now()) {
26
+ if (!secret || !value)
20
27
  return false;
21
- const signature = Uint8Array.from(value.match(/../g), (pair) => parseInt(pair, 16));
22
- return crypto.subtle.verify("HMAC", await keyOf(secret), signature, MESSAGE);
28
+ const m = /^2\.(\d{1,16})\.([0-9a-f]{64})$/.exec(value);
29
+ if (!m)
30
+ return false;
31
+ const expiry = Number(m[1]);
32
+ if (!(expiry > now))
33
+ return false;
34
+ const signature = Uint8Array.from(m[2].match(/../g), (pair) => parseInt(pair, 16));
35
+ return crypto.subtle.verify("HMAC", await keyOf(secret), signature, message(expiry));
23
36
  }
24
37
  /** The page path a preview address names ("/" for the base itself), or null when `pathname` is not one. */
25
38
  export function previewPath(pathname) {
@@ -1,4 +1,4 @@
1
- import { escapeHtml, isResolvedImage, safeHref } from "../richtext-href.js";
1
+ import { escapeHtml, isResolvedImage, safeHref, textOf } from "../richtext-href.js";
2
2
  import { ELEMENT_NODE, IMAGE_NODE } from "../richtext-walk.js";
3
3
  const asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
4
4
  const WRAP = { paragraph: "p", bulletList: "ul", orderedList: "ol", listItem: "li", blockquote: "blockquote" };
@@ -26,7 +26,7 @@ export function richtextChunks(doc) {
26
26
  }
27
27
  switch (node.type) {
28
28
  case "text":
29
- html += withMarks(escapeHtml(node.text ?? ""), node.marks ?? []);
29
+ html += withMarks(escapeHtml(textOf(node.text)), node.marks ?? []);
30
30
  return;
31
31
  case "heading": {
32
32
  const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
@@ -6,7 +6,7 @@
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { createAdminHandler } from "../../admin/handler.js";
8
8
  import { expire } from "../cache.js";
9
- import { DRAFT_COOKIE, PREVIEW_BASE, draftCookieValue } from "../preview.js";
9
+ import { DRAFT_COOKIE, DRAFT_COOKIE_MAX_AGE, PREVIEW_BASE, draftCookieValue } from "../preview.js";
10
10
  import { output } from "smoodly:build";
11
11
  import { adminDeps, secret } from "../server.js";
12
12
  export const prerender = false;
@@ -29,7 +29,7 @@ const handle = createAdminHandler(() => ({
29
29
  enable: async () => {
30
30
  const { context } = effects();
31
31
  context.cookies.set(DRAFT_COOKIE, await draftCookieValue(secret()), {
32
- path: PREVIEW_BASE, httpOnly: true, sameSite: "lax", secure: secure(context),
32
+ path: PREVIEW_BASE, httpOnly: true, sameSite: "lax", secure: secure(context), maxAge: DRAFT_COOKIE_MAX_AGE,
33
33
  });
34
34
  },
35
35
  disable: () => effects().context.cookies.delete(DRAFT_COOKIE, { path: PREVIEW_BASE }),
package/dist/config.js CHANGED
@@ -19,12 +19,12 @@ function normalizeRegistry(config) {
19
19
  }
20
20
  }
21
21
  return {
22
- sections: (config.registry.sections ?? []).map((s) => schemaOnly(s)),
23
- elements: (config.registry.elements ?? []).map((e) => schemaOnly(e)),
22
+ sections: (config.registry.sections ?? []).map((s) => schemaOnly(s, "registry.sections")),
23
+ elements: (config.registry.elements ?? []).map((e) => schemaOnly(e, "registry.elements")),
24
24
  collections,
25
25
  globals: config.registry.globals ?? [],
26
26
  // Page types only: an entry page is a view, registered with defineViews, and schemaOnly refuses it here.
27
- pages: (config.registry.pages ?? []).map((p) => schemaOnly(p)),
27
+ pages: (config.registry.pages ?? []).map((p) => schemaOnly(p, "registry.pages")),
28
28
  };
29
29
  }
30
30
  /** Names are unique per kind (plan 2026-10-02 registration split): a section and a collection may share one. */
@@ -152,6 +152,9 @@ function assertLocales(config) {
152
152
  }
153
153
  if (!supported.includes(def))
154
154
  throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
155
+ const twice = supported.find((code, i) => supported.indexOf(code) !== i);
156
+ if (twice !== undefined)
157
+ throw new Error(`smoodly: the locale "${twice}" is listed twice in supported — list each locale once.`);
155
158
  }
156
159
  function assertTimeZone(config) {
157
160
  if (config.timeZone === undefined)
package/dist/css.js CHANGED
@@ -10,6 +10,10 @@ export const CSS_MAX = 4000;
10
10
  const REFUSED = [
11
11
  [/@import/i, "@import is not allowed."],
12
12
  [/url\s*\(/i, "url() is not allowed."],
13
+ // The other image functions take a bare string, so they can fetch as url() can.
14
+ [/image-set\s*\(/i, "image-set() is not allowed."],
15
+ [/image\s*\(/i, "image() is not allowed."],
16
+ [/src\s*\(/i, "src() is not allowed."],
13
17
  [/expression\s*\(/i, "expression() is not allowed."],
14
18
  [/behavior\s*:/i, "behavior: is not allowed."],
15
19
  [/<\//, '"</" is not allowed.'],
@@ -8,6 +8,9 @@ export function withCustomSchema(sections, toolset) {
8
8
  return [...sections, customSchema(toolset)];
9
9
  }
10
10
  export function resolveCustom(input) {
11
+ // The pre-split shape (custom.RichText) would be dropped without a word; an untyped config can still carry it.
12
+ if (input && "RichText" in input)
13
+ throw new Error("smoodly: custom.RichText is a view — give the bound RichText to defineViews({ RichText }), not to the config.");
11
14
  const breakpoint = input?.breakpoint ?? DEFAULT_BREAKPOINT;
12
15
  if (!isCssLength(breakpoint))
13
16
  throw new Error(`smoodly: custom.breakpoint "${breakpoint}" must be a length like "48rem" or "768px".`);
@@ -14,6 +14,9 @@ export type EntryLocale = {
14
14
  /** The draft version's saved time as the store's own text (spec 2026-09-26 concurrency §1). Null with no pointer. */
15
15
  draftSavedAt: string | null;
16
16
  publishedVersionId: string | null;
17
+ /** When the published snapshot last moved (null never published): what a published read shows as `updatedAt`, so a
18
+ * draft save after it moves nothing on the site (register 2026-09-30). */
19
+ publishedAt: number | null;
17
20
  updatedAt: number;
18
21
  /** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
19
22
  updatedBy: string | null;
@@ -36,7 +36,21 @@ function srcsetOf(cut, width, d) {
36
36
  function frameOverCut(image, rect, box) {
37
37
  const spot = image.hotspot ?? CENTER_HOTSPOT;
38
38
  const hotspot = { x: clamp01((spot.x * image.width - rect.left) / rect.width), y: clamp01((spot.y * image.height - rect.top) / rect.height) };
39
- return frameStyle(frameFor({ width: rect.width, height: rect.height, hotspot }, box));
39
+ return frameFor({ width: rect.width, height: rect.height, hotspot }, box);
40
+ }
41
+ /** `sizes` describes the box; a frame wider than the box shows the cut at `k` times that width, so each length is
42
+ * scaled, or the browser picks a candidate narrower than what it draws (register 2026-09-08). Each entry is an
43
+ * optional media condition in parentheses and a length, which calc() may wrap. */
44
+ function scaleSizes(sizes, k) {
45
+ if (k <= 1.005)
46
+ return sizes;
47
+ const factor = Math.round(k * 1000) / 1000;
48
+ return sizes.split(",").map((entry) => {
49
+ const m = /^(\s*)(\([^)]*\)\s*)?(.+?)(\s*)$/.exec(entry);
50
+ if (!m)
51
+ return entry;
52
+ return `${m[1]}${m[2] ?? ""}calc(${m[3]} * ${factor})${m[4]}`;
53
+ }).join(",");
40
54
  }
41
55
  export function imageMarkup(image, options) {
42
56
  const loading = options.priority ? "eager" : options.loading ?? "lazy";
@@ -71,12 +85,14 @@ export function imageMarkup(image, options) {
71
85
  if (rect && image.cut) {
72
86
  const own = rect.width / rect.height;
73
87
  const box = options.aspect ? parseAspect(options.aspect) : own;
88
+ const frame = Math.abs(box / own - 1) < 0.005 ? null : frameOverCut(image, rect, box);
89
+ const shown = frame && options.sizes ? { sizes: scaleSizes(options.sizes, frame.width / 100) } : sizes;
74
90
  return {
75
91
  box: boxStyle(box),
76
92
  sources,
77
93
  img: {
78
- ...srcsetOf(image.cut, rect.width, delivery), ...sizes, ...tag, width: rect.width, height: rect.height,
79
- style: Math.abs(box / own - 1) < 0.005 ? FULL : frameOverCut(image, rect, box),
94
+ ...srcsetOf(image.cut, rect.width, delivery), ...shown, ...tag, width: rect.width, height: rect.height,
95
+ style: frame ? frameStyle(frame) : FULL,
80
96
  },
81
97
  };
82
98
  }
@@ -56,6 +56,7 @@ export declare function toEntryItem(record: EntryRecord, fields: Record<string,
56
56
  locale: string;
57
57
  locales: Record<string, LocaleAlternate | null>;
58
58
  seo?: Seo;
59
+ draft?: boolean;
59
60
  }): EntryItem;
60
61
  /** One page as a read returns it: no record, no tree. */
61
62
  export declare function toPageItem(hit: SitePage): PageItem;
@@ -3,9 +3,12 @@ import { SEO_KEY } from "./seo.js";
3
3
  export function toEntryItem(record, fields, o) {
4
4
  // The reserved `$seo` values are Smoodly's: they leave as `seo`, never as a field.
5
5
  const { [SEO_KEY]: _seo, ...rest } = fields;
6
+ const row = record.locales[o.locale];
7
+ // A published read shows when the published snapshot last moved; the draft read follows the live row.
8
+ const updatedAt = (o.draft ? null : row?.publishedAt ?? null) ?? row?.updatedAt ?? record.updatedAt;
6
9
  return {
7
10
  id: record.id, path: o.path, locale: o.locale, locales: o.locales,
8
- updatedAt: record.locales[o.locale]?.updatedAt ?? record.updatedAt, fields: rest,
11
+ updatedAt, fields: rest,
9
12
  ...(o.seo ? { seo: o.seo } : {}),
10
13
  };
11
14
  }
@@ -2,7 +2,8 @@ export type SchemaOr<S> = S | {
2
2
  schema: S;
3
3
  };
4
4
  export declare function schemaOf<S extends object>(x: SchemaOr<S>): S;
5
- /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split). */
5
+ /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split) and a
6
+ * plain function, which would pass as a schema named after itself (register 2026-10-03); `where` names the list. */
6
7
  export declare function schemaOnly<S extends {
7
8
  name: string;
8
- }>(x: S): S;
9
+ }>(x: S, where?: string): S;
@@ -2,9 +2,14 @@ export function schemaOf(x) {
2
2
  const carried = x.schema;
3
3
  return carried !== null && typeof carried === "object" ? carried : x;
4
4
  }
5
- /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split). */
6
- export function schemaOnly(x) {
5
+ /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split) and a
6
+ * plain function, which would pass as a schema named after itself (register 2026-10-03); `where` names the list. */
7
+ export function schemaOnly(x, where) {
7
8
  const carried = x.schema;
9
+ if (typeof x === "function" && (carried === null || typeof carried !== "object")) {
10
+ const name = String(x.name || "anonymous");
11
+ throw new Error(`smoodly: ${where ?? "a registration"} holds a function "${name}", not a schema — register the schema (${name}Schema) and give defineViews the component.`);
12
+ }
8
13
  if (carried !== null && typeof carried === "object") {
9
14
  if (carried.kind === "entryPage") {
10
15
  const c = String(carried.collection);
@@ -2,6 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { RichText } from "../richtext-render.js";
3
3
  import { SmoodlyImage } from "../image/SmoodlyImage.js";
4
4
  export function createRichText({ elements = [], sizes } = {}) {
5
+ // A schema where a paired element belongs would fail at the first render on `undefined.name`; name it here (register 2026-10-03).
6
+ for (const e of elements) {
7
+ if (e && typeof e.schema === "object")
8
+ continue;
9
+ const name = e?.name;
10
+ throw new Error(typeof name === "string"
11
+ ? `smoodly: createRichText({ elements }) got the schema "${name}" where a component belongs — pair it first: smoodly.element(${name}Schema, View).`
12
+ : `smoodly: createRichText({ elements }) got ${e === null ? "null" : typeof e} where a paired element belongs.`);
13
+ }
5
14
  const image = ({ image, caption }) => (_jsxs("figure", { children: [_jsx(SmoodlyImage, { image: image, sizes: sizes }), caption !== "" && _jsx("figcaption", { children: caption })] }));
6
15
  function Bound({ doc }) {
7
16
  return _jsx(RichText, { doc: doc, elements: elements, image: image });
@@ -1,4 +1,6 @@
1
1
  import type { ResolvedImage } from "./media.ts";
2
2
  export declare function safeHref(raw: unknown): string | null;
3
+ /** A text node's text as both renderers show it: a string or a number; anything else is nothing, never a throw. */
4
+ export declare const textOf: (v: unknown) => string;
3
5
  export declare function escapeHtml(text: string): string;
4
6
  export declare const isResolvedImage: (v: unknown) => v is ResolvedImage;
@@ -9,6 +9,8 @@ export function safeHref(raw) {
9
9
  const href = raw.replace(/[\t\n\r]/g, "").trim();
10
10
  return href !== "" && SAFE_HREF.test(href) ? href : null;
11
11
  }
12
+ /** A text node's text as both renderers show it: a string or a number; anything else is nothing, never a throw. */
13
+ export const textOf = (v) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : "");
12
14
  const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#x27;" };
13
15
  export function escapeHtml(text) {
14
16
  return text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
9
9
  // (default: a figure over a plain <img>); an `element` node renders the
10
10
  // paired component named in `elements`, nothing when unknown.
11
11
  import { Fragment, createElement } from "react";
12
- import { isResolvedImage, safeHref } from "./richtext-href.js";
12
+ import { isResolvedImage, safeHref, textOf } from "./richtext-href.js";
13
13
  import { ELEMENT_NODE, IMAGE_NODE } from "./richtext-walk.js";
14
14
  /** Lazy by default: body images sit below the fold, and a lazy <img> is
15
15
  * the one React does not hoist a preload hint for on the server. */
@@ -31,7 +31,7 @@ function renderNode(node, r) {
31
31
  return null;
32
32
  const children = Array.isArray(node.content) ? renderNodes(node.content, r) : null;
33
33
  switch (node.type) {
34
- case "text": return withMarks(node.text ?? "", node.marks ?? []);
34
+ case "text": return withMarks(textOf(node.text), node.marks ?? []);
35
35
  case "paragraph": return _jsx("p", { children: children });
36
36
  case "heading": {
37
37
  const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
@@ -53,8 +53,9 @@ function renderNode(node, r) {
53
53
  const Component = typeof a.name === "string" ? r.elements.get(a.name) : undefined;
54
54
  if (!Component)
55
55
  return null;
56
- const fields = a.fields && typeof a.fields === "object" ? a.fields : {};
57
- const settings = a.settings && typeof a.settings === "object" ? a.settings : {};
56
+ // A record only, as the Astro renderer takes it: an array spread as props is nonsense.
57
+ const fields = a.fields && typeof a.fields === "object" && !Array.isArray(a.fields) ? a.fields : {};
58
+ const settings = a.settings && typeof a.settings === "object" && !Array.isArray(a.settings) ? a.settings : {};
58
59
  return _jsx(Component, { ...fields, settings: settings });
59
60
  }
60
61
  default: return children;
package/dist/store.d.ts CHANGED
@@ -195,3 +195,6 @@ export declare function pageRefEdges(trees: PageTree[], sections: SectionLike[],
195
195
  * THERE — every referenced entry must exist, have a row in that locale,
196
196
  * and be published in it. Throws naming the offenders. */
197
197
  export declare function assertPublishable(label: string, tree: PageTree, sections: SectionLike[], entries: EntryStore, locale: string): Promise<void>;
198
+ /** The same rule for any set of reference edges: an entry's or a global item's fields publish only when every
199
+ * entry they reference is published in that locale (register 2026-09-14: pages were gated, entries were not). */
200
+ export declare function assertRefsPublished(label: string, edges: RefEdge[], entries: EntryStore, locale: string): Promise<void>;
package/dist/store.js CHANGED
@@ -83,8 +83,13 @@ export function pageRefEdges(trees, sections, elements) {
83
83
  * THERE — every referenced entry must exist, have a row in that locale,
84
84
  * and be published in it. Throws naming the offenders. */
85
85
  export async function assertPublishable(label, tree, sections, entries, locale) {
86
+ await assertRefsPublished(label, collectTreeRefs(tree, sections), entries, locale);
87
+ }
88
+ /** The same rule for any set of reference edges: an entry's or a global item's fields publish only when every
89
+ * entry they reference is published in that locale (register 2026-09-14: pages were gated, entries were not). */
90
+ export async function assertRefsPublished(label, edges, entries, locale) {
86
91
  const targets = new Map();
87
- for (const edge of collectTreeRefs(tree, sections)) {
92
+ for (const edge of edges) {
88
93
  if (edge.targetCollection === ASSET_COLLECTION)
89
94
  continue; // assets are immediate, never draft-gated
90
95
  if (!targets.has(edge.targetId))
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.54";
1
+ export declare const SMOODLY_VERSION = "0.0.56";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/write-version.mjs from package.json — do not edit.
2
- export const SMOODLY_VERSION = "0.0.54";
2
+ export const SMOODLY_VERSION = "0.0.56";
package/dist/views.js CHANGED
@@ -1,3 +1,20 @@
1
+ /** Every entry of a defineViews list is a paired component; a bare schema or a plain function is named here, where
2
+ * `undefined.name` would have been the word (register 2026-10-03). */
3
+ function assertPaired(key, builder, views) {
4
+ for (const v of views) {
5
+ const schema = v?.schema;
6
+ if (schema && typeof schema === "object")
7
+ continue;
8
+ const name = v?.name;
9
+ if (typeof v === "function") {
10
+ throw new Error(`smoodly: defineViews({ ${key} }) got a plain function "${String(name)}" where a paired component belongs — pair it first: smoodly.${builder}(schema, View).`);
11
+ }
12
+ if (v && typeof v === "object" && typeof name === "string") {
13
+ throw new Error(`smoodly: defineViews({ ${key} }) got the schema "${name}" where a component belongs — pair it first: smoodly.${builder}(${name}Schema, View).`);
14
+ }
15
+ throw new Error(`smoodly: defineViews({ ${key} }) got ${v === null ? "null" : typeof v} where a paired component belongs.`);
16
+ }
17
+ }
1
18
  function match(what, key, schemas, views) {
2
19
  const registered = new Map(schemas.map((s) => [s.name, s]));
3
20
  const viewed = new Set();
@@ -8,6 +25,8 @@ function match(what, key, schemas, views) {
8
25
  if (schema !== v.schema) {
9
26
  throw new Error(`smoodly: the view for ${what} "${v.schema.name}" is paired with a different schema object than the one in the config — pair it with the registered schema.`);
10
27
  }
28
+ if (viewed.has(v.schema.name))
29
+ throw new Error(`smoodly: two views are paired with ${what} "${v.schema.name}" — keep one in defineViews({ ${key} }).`);
11
30
  viewed.add(v.schema.name);
12
31
  }
13
32
  for (const s of schemas) {
@@ -17,6 +36,14 @@ function match(what, key, schemas, views) {
17
36
  }
18
37
  export function defineViews(config, views) {
19
38
  const elements = views.elements ?? [];
39
+ assertPaired("sections", "section", views.sections);
40
+ assertPaired("elements", "element", elements);
41
+ assertPaired("pages", "page", views.pages);
42
+ for (const p of views.pages) {
43
+ if (p.schema.kind !== "page" && p.schema.kind !== "entryPage") {
44
+ throw new Error(`smoodly: defineViews({ pages }) got the ${p.schema.kind} "${p.schema.name}" — pages holds page types and entry pages; ${p.schema.kind}s go to defineViews({ ${p.schema.kind}s }).`);
45
+ }
46
+ }
20
47
  match("section", "sections", config.registry.sections, views.sections);
21
48
  match("element", "elements", config.registry.elements, elements);
22
49
  match("page type", "pages", config.registry.pages, views.pages.filter((p) => p.schema.kind === "page"));
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.54",
3
+ "version": "0.0.56",
4
4
  "description": "Open-source, code-first CMS for Astro and Next.js: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
+ "engines": {
7
+ "node": ">=22.12"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "git+https://github.com/smoodly-io/smoodly.git",
@@ -51,6 +54,7 @@
51
54
  "types": "./dist/richtext/index.d.ts",
52
55
  "default": "./dist/richtext/index.js"
53
56
  },
57
+ "./package.json": "./package.json",
54
58
  "./admin": {
55
59
  "types": "./dist/admin/index.d.ts",
56
60
  "default": "./dist/admin/index.js"