smoodly 0.0.58 → 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.
@@ -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.58";
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.58";
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.58",
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": {