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.
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/forms/MediaGrid.d.ts +3 -2
- package/dist/admin/forms/MediaGrid.js +37 -7
- package/dist/admin/forms/RichtextElementCard.js +2 -1
- package/dist/admin/forms/RichtextImageCard.js +2 -1
- package/dist/admin/forms/richtext-extensions.js +7 -2
- package/dist/admin/ops.d.ts +16 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/EntriesList.js +59 -9
- package/dist/admin/shell/IconRail.js +10 -6
- package/dist/admin/shell/ListView.d.ts +5 -1
- package/dist/admin/shell/ListView.js +3 -3
- package/dist/admin/shell/MediaScreen.js +2 -2
- package/dist/admin/shell/PagesList.js +44 -2
- package/dist/admin/shell/entries-list.d.ts +12 -0
- package/dist/admin/shell/entries-list.js +30 -1
- package/dist/admin/ui/theme.js +26 -2
- package/dist/assemble.js +3 -0
- package/dist/collections.d.ts +4 -0
- package/dist/collections.js +3 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/entry-store.d.ts +33 -4
- package/dist/entry-store.js +19 -6
- package/dist/paths.d.ts +34 -0
- package/dist/paths.js +47 -2
- package/dist/reads.d.ts +1 -1
- package/dist/site.d.ts +3 -1
- package/dist/site.js +1 -1
- package/dist/store.d.ts +7 -1
- package/dist/store.js +4 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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). */
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/collections.d.ts
CHANGED
|
@@ -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>;
|
package/dist/collections.js
CHANGED
|
@@ -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)
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/entry-store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
145
|
-
|
|
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) =>
|
|
229
|
-
|
|
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: {}
|
|
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
|
|
9
|
-
// locale row owns its slug, title, publish state
|
|
10
|
-
//
|
|
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.
|
|
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.
|
|
2
|
+
export const SMOODLY_VERSION = "0.0.59";
|
package/package.json
CHANGED