smoodly 0.0.57 → 0.0.59

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.
@@ -1,4 +1,5 @@
1
1
  import { localeStatusOf } from "./pages-tree.js";
2
+ import { orderIn } from "../../paths.js";
2
3
  /** The entry's title in the selected locale, else the default locale's, else any locale's;
3
4
  * "Untitled" when every one is empty (spec 2026-09-14 §4). Display only — a shared
4
5
  * title field reads the same from every locale. */
@@ -23,7 +24,8 @@ export function localizedFieldKeys(meta) {
23
24
  return Object.entries(meta.fields).filter(([key, d]) => key === "slug" || d.localized === true).map(([key]) => key);
24
25
  }
25
26
  export function entryRows(records, meta, registry, locale) {
26
- return records.map((record) => {
27
+ // A manual order is the selected locale's own (2026-11-13); the list arrives in the default locale's.
28
+ return (meta.manual ? orderIn(records, locale) : records).map((record) => {
27
29
  const row = record.locales[locale];
28
30
  return {
29
31
  id: record.id,
@@ -34,6 +36,33 @@ export function entryRows(records, meta, registry, locale) {
34
36
  };
35
37
  });
36
38
  }
39
+ /** How many entries a list page shows: 20 unless the editor chose more (2026-11-13). */
40
+ export const PAGE_SIZES = [20, 100, 500];
41
+ const PAGE_SIZE_KEY = "smoodly-page-size";
42
+ /** The stored size when it is one of the three, else 20. */
43
+ export function resolvePageSize(stored) {
44
+ return PAGE_SIZES.find((s) => String(s) === stored) ?? PAGE_SIZES[0];
45
+ }
46
+ export function rememberedPageSize() {
47
+ let stored = null;
48
+ try {
49
+ stored = window.localStorage.getItem(PAGE_SIZE_KEY);
50
+ }
51
+ catch { /* storage unavailable: 20 is fine */ }
52
+ return resolvePageSize(stored);
53
+ }
54
+ export function rememberPageSize(size) {
55
+ try {
56
+ window.localStorage.setItem(PAGE_SIZE_KEY, String(size));
57
+ }
58
+ catch { /* a convenience only */ }
59
+ }
60
+ /** The pager's "21–40 of 134": null while the list fits the smallest page, where there is nothing to page. */
61
+ export function pageRange(total, offset, shown) {
62
+ if (total <= PAGE_SIZES[0] || shown === 0)
63
+ return null;
64
+ return { from: offset + 1, to: offset + shown };
65
+ }
37
66
  /** The list drags only when the order IS the `sort` column and the rows
38
67
  * are siblings: a nested collection's flat index is not a sibling index
39
68
  * (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */
@@ -85,7 +85,8 @@ const TOKENS_DARK = `
85
85
  --frame:#0F1120; --sheet:${SHEET_DARK};
86
86
  --canvas:#131526; --surface:#242840; --surface-form:#242840; --surface-inset:#1B1E30;
87
87
  --menu-bg:#242840;
88
- --input-bg:#1B1E30; --input-hover-bg:#1B1E30; --input-focus-bg:#1B1E30;
88
+ /* A field's edge is drawn in dark: a dialog's fields take the card's own tone there (2026-11-13). */
89
+ --input-bg:#1B1E30; --input-hover-bg:#1B1E30; --input-focus-bg:#1B1E30; --input-border:var(--line-dashed);
89
90
  --line:#33384F; --line-dashed:#444A63; --line-soft:#33384F;
90
91
  --text:#EEF0F5; --text-secondary:#C5C8D2; --text-muted:#9A9FB3; --text-subtle:#7C819A; --text-disabled:#4F5570;
91
92
  --hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09); --control-hover:#2C3049; --canvas-ground:var(--control-hover);
@@ -233,7 +234,8 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
233
234
  .sm-rail__btn[aria-current="page"]:hover,.sm-rail__btn[aria-expanded="true"]:hover{color:var(--rail-active-fg)}
234
235
  .sm-rail__btn[aria-current="page"] .sm-rail__tile,.sm-rail__btn[aria-expanded="true"] .sm-rail__tile{background:var(--rail-active-bg)}
235
236
  .sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
236
- .sm-rail__label{letter-spacing:.01em;white-space:nowrap}
237
+ /* A collection's own title can be long: it ends in an ellipsis inside the rail (2026-11-13). */
238
+ .sm-rail__label{letter-spacing:.01em;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}
237
239
  /* Not greyed: the reference renders every inactive item at --text and marks
238
240
  the unavailable ones only by being inert (no pointer, no hover). */
239
241
  .sm-rail__btn[aria-disabled="true"]{cursor:default}
@@ -494,6 +496,17 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
494
496
  }
495
497
  .sm-floatbar .sm-richtext__link{padding:0 4px 4px}
496
498
  .sm-floatbar .sm-richtext__menu{position:static;width:300px}
499
+ /* The gap cursor, the caret beside a card with no text line next to it:
500
+ TipTap's own rule draws it black and reaches the admin document only,
501
+ so both mounts take it from here, in the text's colour. */
502
+ .sm-richtext__content .ProseMirror-gapcursor,.sm-inline-richtext .ProseMirror-gapcursor{display:none;pointer-events:none;position:absolute;margin:0}
503
+ .sm-richtext__content .ProseMirror-gapcursor:after,.sm-inline-richtext .ProseMirror-gapcursor:after{content:"";display:block;position:absolute;top:-2px;width:20px;border-top:1px solid currentColor;animation:sm-gapcursor-blink 1.1s steps(2,start) infinite}
504
+ .sm-richtext__content.ProseMirror-focused .ProseMirror-gapcursor,.sm-inline-richtext.ProseMirror-focused .ProseMirror-gapcursor{display:block}
505
+ @keyframes sm-gapcursor-blink{to{visibility:hidden}}
506
+ /* Where a dragged card lands, and the grip that says a card's head drags. */
507
+ .sm-dropcursor{background:var(--accent,currentColor);border-radius:1px}
508
+ .sm-rtcard__grip{flex:none;display:grid;place-items:center;width:14px;margin-left:-4px;color:var(--text-subtle,currentColor);opacity:.7}
509
+ .sm-rtcard__head:hover .sm-rtcard__grip{opacity:1}
497
510
  /* The canvas editor root: ProseMirror's base rules (TipTap injects its
498
511
  stylesheet into the admin document, never the iframe) and nothing of the
499
512
  widget's typography — the site's CSS styles the content. */
@@ -551,6 +564,14 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
551
564
  /* The rows sit in a centred column of at most 1200px inside the card,
552
565
  under --table-top of air reserved for the coming search and filters. */
553
566
  .sm-table{display:flex;flex-direction:column;padding:var(--table-pad);padding-top:var(--table-top);max-width:1200px;margin:0 auto}
567
+ /* The filter sits in that air, so the column heads stay where they are on a list without one (2026-11-13). */
568
+ .sm-table--tools{padding-top:20px}
569
+ .sm-table__tools{display:flex;align-items:center;gap:12px;height:40px;margin-bottom:20px;padding:0 8px}
570
+ .sm-table__tools .sm-input{max-width:320px}
571
+ /* The pager: the count on the left, the page size and Previous / Next on the right. */
572
+ .sm-table__foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:16px 8px 8px;color:var(--text-muted);font-size:var(--meta-size)}
573
+ .sm-table__pager{display:flex;align-items:center;gap:12px}
574
+ .sm-seg--size .sm-seg__opt{min-width:44px;font-size:var(--text-sm)}
554
575
  .sm-table__head,.sm-row{display:grid;gap:12px;align-items:center;padding:0 8px}
555
576
  .sm-table__head{
556
577
  height:40px;border-bottom:var(--table-rule);font-family:var(--meta-font);font-weight:500;
@@ -1068,6 +1089,9 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
1068
1089
  .sm-mediacard__thumb{display:block;aspect-ratio:4/3;border-radius:var(--radius-sm);overflow:hidden;background:var(--surface-inset)}
1069
1090
  .sm-mediacard__img{width:100%;height:100%;object-fit:cover;display:block}
1070
1091
  .sm-thumb--chip{display:grid;place-items:center;width:100%;height:100%;font-weight:600;color:var(--text-muted);font-size:var(--text-sm)}
1092
+ /* A video with no poster: the browser's own opening frame over the chip, which stays where it cannot draw one. */
1093
+ .sm-thumb--video{position:relative}
1094
+ .sm-thumb--video>video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
1071
1095
  .sm-mediacard__name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1072
1096
  .sm-mediacard__meta{font-size:var(--text-sm);color:var(--text-muted)}
1073
1097
  .sm-media{display:flex;flex-direction:column;gap:6px}
package/dist/assemble.js CHANGED
@@ -58,4 +58,7 @@ export function checkEntryQuery(schema, collection, o) {
58
58
  if (o.limit !== undefined && (!Number.isInteger(o.limit) || o.limit < 0)) {
59
59
  throw new Error(`smoodly: getEntries("${collection}"): limit must be a non-negative integer.`);
60
60
  }
61
+ if (o.offset !== undefined && (!Number.isInteger(o.offset) || o.offset < 0)) {
62
+ throw new Error(`smoodly: getEntries("${collection}"): offset must be a non-negative integer.`);
63
+ }
61
64
  }
@@ -26,8 +26,11 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string
26
26
  depth: number;
27
27
  };
28
28
  order?: EntryOrder;
29
+ /** `showInNav`: the collection gets its own item in the admin's main navigation, after Pages, in place of its
30
+ * line in the Collections menu (2026-11-13). Absent = in the menu. */
29
31
  admin?: {
30
32
  listColumns?: string[];
33
+ showInNav?: boolean;
31
34
  };
32
35
  /** Entries carry SEO & Social values (spec 2026-09-29 content API contract §7): stored under `$seo`, empty values falling back on `titleField` and the named fields. */
33
36
  seo?: CollectionSeo;
@@ -48,6 +51,7 @@ export declare function collection<F extends BuilderMap, const N extends string
48
51
  order?: EntryOrder;
49
52
  admin?: {
50
53
  listColumns?: string[];
54
+ showInNav?: boolean;
51
55
  };
52
56
  seo?: CollectionSeo;
53
57
  }): CollectionSchema<F, N>;
@@ -11,6 +11,9 @@ export function collection(input) {
11
11
  if (Object.prototype.hasOwnProperty.call(input.fields, SEO_KEY)) {
12
12
  throw new Error(`smoodly: collection "${input.name}": "${SEO_KEY}" is a reserved key.`);
13
13
  }
14
+ if (input.admin?.showInNav !== undefined && typeof input.admin.showInNav !== "boolean") {
15
+ throw new Error(`smoodly: collection "${input.name}": admin.showInNav is true or false.`);
16
+ }
14
17
  if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
15
18
  throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
16
19
  }
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Descriptor } from "./fields.ts";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
3
  import type { GlobalSchema } from "./global.ts";
4
+ import { type LocaleOrder } from "./paths.ts";
4
5
  import { type Addresses } from "./addresses.ts";
5
6
  import type { Anchors } from "./anchors.ts";
6
7
  import { type ImageQuality } from "./media-cut.ts";
@@ -32,9 +33,12 @@ export type SmoodlyConfig = {
32
33
  /** Global content: code-declared singletons (spec 2026-09-07). */
33
34
  globals?: GlobalSchema[];
34
35
  };
36
+ /** `order`: whether the page tree and manually ordered collections keep one order for every locale
37
+ * ("shared", the default) or each locale its own ("perLocale"): a drag then reorders the locale on screen alone. */
35
38
  locales: {
36
39
  default: string;
37
40
  supported: string[];
41
+ order?: LocaleOrder;
38
42
  };
39
43
  /** The site's time zone, an IANA name such as "Europe/Helsinki" (spec
40
44
  * 2026-09-25 date pickers §5): editors pick datetimes in it, views
package/dist/config.js CHANGED
@@ -155,6 +155,10 @@ function assertLocales(config) {
155
155
  const twice = supported.find((code, i) => supported.indexOf(code) !== i);
156
156
  if (twice !== undefined)
157
157
  throw new Error(`smoodly: the locale "${twice}" is listed twice in supported — list each locale once.`);
158
+ const order = config.locales.order;
159
+ if (order !== undefined && order !== "shared" && order !== "perLocale") {
160
+ throw new Error(`smoodly: locales.order is ${JSON.stringify(order)} — use "shared" (one order for every locale) or "perLocale".`);
161
+ }
158
162
  }
159
163
  function assertTimeZone(config) {
160
164
  if (config.timeZone === undefined)
@@ -7,6 +7,8 @@ import { type LocaleSet } from "./paths.ts";
7
7
  export type EntryLocale = {
8
8
  /** The entry's segment in this locale; null claims no URL. */
9
9
  slug: string | null;
10
+ /** The entry's place in a manual order as this locale lists it; null sorts last (2026-11-13). */
11
+ sort: number | null;
10
12
  status: "draft" | "published";
11
13
  /** ONLY the .localized() fields, for this locale. */
12
14
  fields: Record<string, unknown>;
@@ -29,7 +31,6 @@ export type EntryRecord = {
29
31
  fields: Record<string, unknown>;
30
32
  /** Only the locales present. Presence IS the row. */
31
33
  locales: Record<string, EntryLocale>;
32
- sort: number | null;
33
34
  createdAt: number;
34
35
  updatedAt: number;
35
36
  };
@@ -83,6 +84,25 @@ export type ListOptions = {
83
84
  */
84
85
  parent?: string | null;
85
86
  };
87
+ /** The admin's list of a collection, a page at a time (2026-11-13): every entry of the collection, present in
88
+ * `locale` or not, in the collection's order as `locale` lists it. */
89
+ export type EntryPageQuery = {
90
+ locale: string;
91
+ /** How many entries, and how many to skip first. */
92
+ limit: number;
93
+ offset?: number;
94
+ /** Only entries whose title field holds this text, in the shared fields or in any locale's; matched without
95
+ * regard to ASCII case. Ignored for a collection with no `titleField`, and when empty. */
96
+ search?: string;
97
+ };
98
+ /** `total` counts every entry the query matches, before `limit` and `offset`. */
99
+ export type EntryPage = {
100
+ entries: EntryRecord[];
101
+ total: number;
102
+ };
103
+ /** Whether an entry's title field holds `search`, in the shared fields or any locale row's (the memory store's
104
+ * reading; the SQLite store says the same in SQL). */
105
+ export declare function matchesSearch(record: EntryRecord, titleField: string, search: string): boolean;
86
106
  export type SnapshotQuery = {
87
107
  locale: string;
88
108
  /** true: the live row (the editor preview); false: the published snapshot. */
@@ -90,6 +110,8 @@ export type SnapshotQuery = {
90
110
  where?: Where;
91
111
  order?: EntryOrder;
92
112
  limit?: number;
113
+ /** Rows to skip before `limit` counts, after the order: a paginated list's page (2026-11-13). */
114
+ offset?: number;
93
115
  parent?: string | null;
94
116
  /** Only these entries (getEntry by id). */
95
117
  ids?: string[];
@@ -120,6 +142,8 @@ export interface EntryStore {
120
142
  create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
121
143
  get(collection: string, id: string): Promise<EntryRecord | null>;
122
144
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
145
+ /** One page of the admin's list with the count of every match: filtered, ordered and cut in the store. */
146
+ page(collection: string, q: EntryPageQuery): Promise<EntryPage>;
123
147
  /** Filters, orders and limits over the version SHOWN: the published snapshot (a row
124
148
  * without one falls back to the live merge), or the live row when `draft` (spec
125
149
  * 2026-09-29 content API contract §5). Only rows present in `locale`; the published
@@ -173,15 +197,20 @@ export interface EntryStore {
173
197
  * among the destination siblings; omitted, it appends; out-of-range
174
198
  * values clamp. Destination siblings are renumbered 1..N afterward.
175
199
  * Like `reorder`, `move` does NOT bump `updatedAt`.
200
+ * `to.locale` is the locale whose list `index` counts in (the default
201
+ * when omitted); under `locales.order: "perLocale"` only that locale's
202
+ * order changes, as PageStore.move.
176
203
  */
177
204
  move(collection: string, id: string, to: {
178
205
  parentId: string | null;
179
206
  index?: number;
207
+ locale?: string;
180
208
  }, options?: {
181
209
  redirect?: boolean;
182
210
  }): Promise<EntryRecord>;
183
- /** Rewrite manual order to 1..N for the given ids, one batched pass. */
184
- reorder(collection: string, orderedIds: string[]): Promise<void>;
211
+ /** Rewrite manual order to 1..N for the given ids, one batched pass: in `locale` alone under
212
+ * `locales.order: "perLocale"` when one is named, else in every locale. */
213
+ reorder(collection: string, orderedIds: string[], locale?: string): Promise<void>;
185
214
  /** The refs index's reverse question: who points at this entry? */
186
215
  referencesTo(collection: string, id: string): Promise<EntryUsage[]>;
187
216
  /** Reverse lookup by id alone (ids are globally unique) — the fan-out walk. */
@@ -263,4 +292,4 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
263
292
  * `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
264
293
  * follow-ups for the mixed-type, JSON-null and non-ASCII edges).
265
294
  */
266
- export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
295
+ export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number, locale: string): (a: EntryRecord, b: EntryRecord) => number;
@@ -1,8 +1,8 @@
1
1
  // The collection-entry persistence contract — the entries counterpart
2
2
  // to PageStore. An entry is ONE node (identity, tree position, the
3
3
  // SHARED fields) with one row per locale it exists in (spec 2026-09-06
4
- // §7): the locale row owns its slug, its publish state and the
5
- // .localized() fields, and — for a collection registered with
4
+ // §7): the locale row owns its slug, its publish state, its manual
5
+ // position (`sort`, 2026-11-13) and the .localized() fields, and — for a collection registered with
6
6
  // every collection — a history of merged snapshots, one row per publish. Nothing falls back
7
7
  // to another locale. Writes always flow through this API (never raw
8
8
  // table access), which is what lets the refs index stay correct: every
@@ -10,7 +10,7 @@
10
10
  // field set. Reorder rewrites sort 1..N in one pass and NEVER touches
11
11
  // updatedAt — order is a property of the collection, not an edit.
12
12
  import { collectEntryRefs } from "./refs.js";
13
- import { slugOf, unsupportedLocale } from "./paths.js";
13
+ import { slugOf, sortIn, unsupportedLocale } from "./paths.js";
14
14
  /** The field types whose value is an array: a scalar `where` on one is
15
15
  * containment, and any-of on one is refused by the site layer (both
16
16
  * adapters read the text form of the whole array, never its elements). */
@@ -29,6 +29,18 @@ export function matchesWhere(fields, where) {
29
29
  return Array.isArray(have) ? have.includes(want) : have === want;
30
30
  });
31
31
  }
32
+ /** The text a search compares: ASCII letters folded, as SQLite's LIKE folds them (the two stores agree). */
33
+ const foldSearch = (text) => text.replace(/[A-Z]/g, (c) => c.toLowerCase());
34
+ /** Whether an entry's title field holds `search`, in the shared fields or any locale row's (the memory store's
35
+ * reading; the SQLite store says the same in SQL). */
36
+ export function matchesSearch(record, titleField, search) {
37
+ const needle = foldSearch(search);
38
+ const holds = (fields) => {
39
+ const v = fields[titleField];
40
+ return (typeof v === "string" || typeof v === "number") && foldSearch(String(v)).includes(needle);
41
+ };
42
+ return holds(record.fields) || Object.values(record.locales).some((row) => holds(row.fields));
43
+ }
32
44
  export function schemaOf(collections, name) {
33
45
  const schema = collections.find((c) => c.name === name);
34
46
  if (!schema)
@@ -138,11 +150,12 @@ export function entryTitleIn(record, locale, schema) {
138
150
  * `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
139
151
  * follow-ups for the mixed-type, JSON-null and non-ASCII edges).
140
152
  */
141
- export function compareEntries(order, seq) {
153
+ export function compareEntries(order, seq, locale) {
142
154
  return (a, b) => {
143
155
  if (order === "manual") {
144
- const d = (a.sort ?? Infinity) - (b.sort ?? Infinity);
145
- return d !== 0 ? d : seq(a) - seq(b);
156
+ // The manual order is `locale`'s own (paths.ts sortIn); Infinity on both sides is a tie.
157
+ const x = sortIn(a, locale), y = sortIn(b, locale);
158
+ return x !== y ? (x < y ? -1 : 1) : seq(a) - seq(b);
146
159
  }
147
160
  const dir = order.direction === "asc" ? 1 : -1;
148
161
  const value = (e) => order.by === "createdAt" ? e.createdAt : order.by === "updatedAt" ? e.updatedAt : e.fields[order.by];
package/dist/paths.d.ts CHANGED
@@ -9,10 +9,42 @@ export declare function assertSegment(s: string, what: string): void;
9
9
  * trailing or doubled hyphens. The result passes isSegment
10
10
  * or is "" — a title with no Latin letters or digits makes no slug. */
11
11
  export declare function slugify(text: string): string;
12
+ /** `order`: whether pages and manually ordered entries keep ONE order for every locale ("shared", the default) or
13
+ * each locale its own ("perLocale") — DESIGN.md, Localization (2026-11-13). */
14
+ export type LocaleOrder = "shared" | "perLocale";
12
15
  export type LocaleSet = {
13
16
  default: string;
14
17
  supported: string[];
18
+ order?: LocaleOrder;
15
19
  };
20
+ /** What order needs of a node: each locale row's own position among the node's siblings. */
21
+ export type Ordered = {
22
+ locales: Record<string, {
23
+ sort?: number | null;
24
+ }>;
25
+ };
26
+ /** A node's position as one locale lists it: that locale's row's; a node absent there stands where its first other
27
+ * row (by locale code) puts it, so under a shared order it keeps its place. Infinity = no position, listed last. */
28
+ export declare function sortIn(node: Ordered, locale: string): number;
29
+ /** The nodes in one locale's order. A stable sort: ties keep the input's order, so callers pass creation order. */
30
+ export declare function orderIn<T extends Ordered>(nodes: readonly T[], locale: string): T[];
31
+ /** The position a new last sibling takes in `locale`. */
32
+ export declare function nextSort(siblings: readonly Ordered[], locale: string): number;
33
+ type Placed = {
34
+ locales: Record<string, {
35
+ sort: number | null;
36
+ }>;
37
+ };
38
+ /** A move's order writes. `siblings` are the destination's other children in creation order; `index` is the node's
39
+ * final 0-based place in the list as `view` shows it (omitted or out of range, it clamps; omitted appends).
40
+ * Shared: every row of every sibling takes the one new numbering. Per locale: only `view`'s rows are renumbered,
41
+ * and after a reparent the node goes last in its other locales. Mutates the rows; returns the nodes it changed. */
42
+ export declare function placeNode<T extends Placed>(siblings: readonly T[], node: T, to: {
43
+ view: string;
44
+ index?: number;
45
+ perLocale: boolean;
46
+ reparented: boolean;
47
+ }): T[];
16
48
  /** ONE text for both stores: a locale outside the config set. */
17
49
  export declare const unsupportedLocale: (locale: string) => Error;
18
50
  export declare function assertSupportedLocale(locale: string, set: LocaleSet): void;
@@ -65,6 +97,7 @@ export type PageLike = {
65
97
  /** The locales the node exists in, each with the node's OWN segment there. Presence IS the key (spec 2026-09-06 §3). */
66
98
  locales: Record<string, {
67
99
  slug: string;
100
+ sort?: number | null;
68
101
  }>;
69
102
  };
70
103
  export type PagePathOptions = {
@@ -154,3 +187,4 @@ export type PageTreeNode<T extends PageLike> = {
154
187
  children: PageTreeNode<T>[];
155
188
  };
156
189
  export declare function buildPageTree<T extends PageLike>(records: T[], locale: string, opts: PagePathOptions): PageTreeNode<T>[];
190
+ export {};
package/dist/paths.js CHANGED
@@ -26,6 +26,50 @@ export function slugify(text) {
26
26
  .replace(/[^a-z0-9]+/g, "-")
27
27
  .replace(/^-+|-+$/g, "");
28
28
  }
29
+ /** A node's position as one locale lists it: that locale's row's; a node absent there stands where its first other
30
+ * row (by locale code) puts it, so under a shared order it keeps its place. Infinity = no position, listed last. */
31
+ export function sortIn(node, locale) {
32
+ const own = node.locales[locale];
33
+ if (own)
34
+ return own.sort ?? Infinity;
35
+ const other = Object.keys(node.locales).sort()[0];
36
+ return (other === undefined ? null : node.locales[other].sort) ?? Infinity;
37
+ }
38
+ /** The nodes in one locale's order. A stable sort: ties keep the input's order, so callers pass creation order. */
39
+ export function orderIn(nodes, locale) {
40
+ return [...nodes].sort((a, b) => { const x = sortIn(a, locale), y = sortIn(b, locale); return x === y ? 0 : x < y ? -1 : 1; });
41
+ }
42
+ /** The position a new last sibling takes in `locale`. */
43
+ export function nextSort(siblings, locale) {
44
+ return siblings.reduce((max, s) => { const n = sortIn(s, locale); return n !== Infinity && n > max ? n : max; }, 0) + 1;
45
+ }
46
+ /** A move's order writes. `siblings` are the destination's other children in creation order; `index` is the node's
47
+ * final 0-based place in the list as `view` shows it (omitted or out of range, it clamps; omitted appends).
48
+ * Shared: every row of every sibling takes the one new numbering. Per locale: only `view`'s rows are renumbered,
49
+ * and after a reparent the node goes last in its other locales. Mutates the rows; returns the nodes it changed. */
50
+ export function placeNode(siblings, node, to) {
51
+ const changed = new Set();
52
+ const set = (n, locale, sort) => {
53
+ const row = n.locales[locale];
54
+ if (row && row.sort !== sort) {
55
+ row.sort = sort;
56
+ changed.add(n);
57
+ }
58
+ };
59
+ const ordered = orderIn(siblings, to.view);
60
+ ordered.splice(Math.min(Math.max(to.index ?? ordered.length, 0), ordered.length), 0, node);
61
+ if (!to.perLocale) {
62
+ ordered.forEach((n, i) => { for (const locale of Object.keys(n.locales))
63
+ set(n, locale, i + 1); });
64
+ return [...changed];
65
+ }
66
+ ordered.forEach((n, i) => set(n, to.view, i + 1));
67
+ if (to.reparented)
68
+ for (const locale of Object.keys(node.locales))
69
+ if (locale !== to.view)
70
+ set(node, locale, nextSort(siblings, locale));
71
+ return [...changed];
72
+ }
29
73
  /** ONE text for both stores: a locale outside the config set. */
30
74
  export const unsupportedLocale = (locale) => new Error(`smoodly: "${locale}" is not a supported locale.`);
31
75
  export function assertSupportedLocale(locale, set) {
@@ -225,8 +269,9 @@ export function entryAlternates(entry, set, advertised, pathIn) {
225
269
  }
226
270
  export function buildPageTree(records, locale, opts) {
227
271
  const byId = (id) => records.find((r) => r.id === id);
228
- const build = (parentId) => records
229
- .filter((r) => r.parentId === parentId)
272
+ const build = (parentId) =>
273
+ // Siblings in the locale's own order (2026-11-13); the input's order settles ties.
274
+ orderIn(records.filter((r) => r.parentId === parentId), locale)
230
275
  .map((r) => ({
231
276
  record: r,
232
277
  path: pagePathIn(r, byId, locale, opts),
package/dist/reads.d.ts CHANGED
@@ -47,7 +47,7 @@ export type SmoodlyQueries<C extends readonly CollectionSchema<any, string>[], S
47
47
  getPageTree(options?: QueryOptions): Promise<PageTreeItem[]>;
48
48
  /** A page by locale-relative path ("/company/about-us") or id. */
49
49
  getPage(pathOrId: string, options?: QueryOptions): Promise<PageItem | null>;
50
- /** Published entries of a collection: `where` (shared fields), `order`, `limit`, `parent` run in the store. */
50
+ /** Published entries of a collection: `where` (shared fields), `order`, `limit`, `offset`, `parent` run in the store. */
51
51
  getEntries<N extends C[number]["name"]>(collection: N, options?: EntriesQuery): Promise<TypedEntryItem<C, N>[]>;
52
52
  getEntry<N extends C[number]["name"]>(collection: N, idOrPath: string, options?: QueryOptions): Promise<TypedEntryItem<C, N> | null>;
53
53
  getGlobal<N extends S[number]["name"]>(item: N, options?: QueryOptions): Promise<GlobalValue<S, N> | null>;
package/dist/site.d.ts CHANGED
@@ -108,13 +108,15 @@ export type PageTreeItem = {
108
108
  export type EntryTreeItem = EntryItem & {
109
109
  children: EntryTreeItem[];
110
110
  };
111
- /** `where`, `limit`, `parent` and `order` run in the store (spec 2026-09-11 §3); `draft` reads live rows inside the editor preview. */
111
+ /** `where`, `limit`, `offset`, `parent` and `order` run in the store (spec 2026-09-11 §3); `draft` reads live rows inside the editor preview.
112
+ * `offset` skips that many entries after the order, before `limit` counts: page N of a list is `{ limit, offset: (N - 1) * limit }`. */
112
113
  export type EntryQuery = {
113
114
  locale?: string;
114
115
  parent?: string | null;
115
116
  order?: EntryOrder;
116
117
  where?: Where;
117
118
  limit?: number;
119
+ offset?: number;
118
120
  draft?: boolean;
119
121
  /** Only these entries (`getEntry` by id). */
120
122
  ids?: string[];
package/dist/site.js CHANGED
@@ -48,7 +48,7 @@ export function siteStatics(options) {
48
48
  kind: "page",
49
49
  locale,
50
50
  // No record yet: the id only has to be stable and never a stored one.
51
- record: { id: `code:${template}`, parentId: null, template, fixed: spec.key, locales: {}, sort: null },
51
+ record: { id: `code:${template}`, parentId: null, template, fixed: spec.key, locales: {} },
52
52
  template,
53
53
  tree: { zones: {} },
54
54
  title: spec.title,
package/dist/store.d.ts CHANGED
@@ -11,6 +11,8 @@ export type PageLocale = {
11
11
  /** The node's OWN segment in this locale; the URL is derived (paths.ts). */
12
12
  slug: string;
13
13
  title: string;
14
+ /** The node's position among its siblings as this locale lists them; null sorts last. Read with paths.ts orderIn. */
15
+ sort: number | null;
14
16
  status: "draft" | "published";
15
17
  draftVersionId: string | null;
16
18
  /** The draft version's saved time as the store's own text — the token's second half (spec 2026-09-26 concurrency §1). Null with no pointer. */
@@ -25,7 +27,6 @@ export type PageRecord = {
25
27
  parentId: string | null;
26
28
  /** null = folder: a node with a title and a position but no page behind it. */
27
29
  template: string | null;
28
- sort: number | null;
29
30
  /** The owner key when code owns the record (spec 2026-09-29 nested paths §3); absent/null = an editor's page. */
30
31
  fixed?: string | null;
31
32
  /** One entry per locale the page exists in. */
@@ -99,10 +100,15 @@ export interface PageStore {
99
100
  * removed from the sibling list first, then spliced back in at `index`.
100
101
  * Omitted, it appends; out-of-range values clamp to [0, siblings.length].
101
102
  * Refused when the destination lacks one of the page's locales.
103
+ * `to.locale` is the locale whose list `index` counts in (the default
104
+ * locale when omitted). Under `locales.order: "perLocale"` only that
105
+ * locale's order changes, and a reparented page goes last in its other
106
+ * locales; under "shared" every locale takes the new order.
102
107
  */
103
108
  move(id: string, to: {
104
109
  parentId: string | null;
105
110
  index?: number;
111
+ locale?: string;
106
112
  }, options?: {
107
113
  redirect?: boolean;
108
114
  }): Promise<PageRecord>;
package/dist/store.js CHANGED
@@ -5,9 +5,10 @@
5
5
  // implements the same interface over pages/page_locales/page_versions.
6
6
  //
7
7
  // The model (spec 2026-09-06): a page is ONE node with ONE row per
8
- // locale it exists in. The node holds identity and tree position; each
9
- // locale row owns its slug, title, publish state and version pointers,
10
- // and each locale has its own history. Nothing falls back to another
8
+ // locale it exists in. The node holds identity and its parent; each
9
+ // locale row owns its slug, title, publish state, version pointers and
10
+ // its position among the siblings (`sort`, 2026-11-13: shared or per
11
+ // locale by `locales.order`), and each locale has its own history. Nothing falls back to another
11
12
  // locale — presence IS the row.
12
13
  import { ASSET_COLLECTION } from "./media.js";
13
14
  import { collectTreeRefs } from "./refs.js";
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.57";
1
+ export declare const SMOODLY_VERSION = "0.0.59";
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.57";
2
+ export const SMOODLY_VERSION = "0.0.59";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.57",
3
+ "version": "0.0.59",
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
6
  "engines": {