smoodly 0.0.7 → 0.0.9
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/README.md +224 -4
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +64 -8
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +85 -7
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +84 -1
- package/dist/admin/ops.d.ts +22 -1
- package/dist/admin/richtext.d.ts +13 -11
- package/dist/admin/richtext.js +94 -13
- package/dist/admin/serialize.d.ts +15 -0
- package/dist/admin/serialize.js +11 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +8 -2
- package/dist/admin/shell/EntryForm.js +1 -1
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +13 -0
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +43 -13
- package/dist/collections.d.ts +4 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +35 -1
- package/dist/entry-store.d.ts +9 -1
- package/dist/entry-store.js +32 -4
- package/dist/fields.d.ts +16 -1
- package/dist/fields.js +49 -4
- package/dist/index.d.ts +8 -1
- package/dist/index.js +7 -1
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/refs.js +13 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +41 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +4 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -0
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +10 -0
- package/dist/site.js +28 -1
- package/dist/sql-space.d.ts +5 -1
- package/dist/sql-space.js +9 -10
- package/dist/sql.js +1 -19
- package/dist/supabase-entry-store.d.ts +9 -1
- package/dist/supabase-entry-store.js +60 -7
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -1,17 +1,50 @@
|
|
|
1
1
|
import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
|
|
2
|
+
const RESERVED_NAME = "shared"; // the placement node type and the admin route
|
|
2
3
|
function assertUniqueNames(config) {
|
|
3
4
|
const seen = new Set();
|
|
5
|
+
const sectionNameList = (config.registry.sections ?? []).map((s) => s.schema.name);
|
|
6
|
+
const sectionNames = new Set(sectionNameList);
|
|
4
7
|
const all = [
|
|
5
|
-
...
|
|
8
|
+
...sectionNameList,
|
|
6
9
|
...(config.registry.elements ?? []).map((e) => e.schema.name),
|
|
7
10
|
...(config.registry.collections ?? []).map((c) => c.name),
|
|
8
11
|
...(config.registry.pages ?? []).filter((p) => p.schema.kind !== "entryPage").map((p) => p.schema.name),
|
|
9
12
|
];
|
|
10
13
|
for (const name of all) {
|
|
14
|
+
if (name === RESERVED_NAME)
|
|
15
|
+
throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
|
|
11
16
|
if (seen.has(name))
|
|
12
17
|
throw new Error(`smoodly: duplicate registered name "${name}".`);
|
|
13
18
|
seen.add(name);
|
|
14
19
|
}
|
|
20
|
+
// Shared items get their own pass, after every other kind is settled: a
|
|
21
|
+
// shared item's name must be unique among shared items, and must not
|
|
22
|
+
// collide with any collection/element/page/section name above — EXCEPT a
|
|
23
|
+
// visual item may reuse the name of the registered section it renders
|
|
24
|
+
// (spec 2026-09-07 §2's example pairs a "footer" section with a "footer"
|
|
25
|
+
// item). That exemption is narrow: it only excuses a match against the
|
|
26
|
+
// item's own, actually-registered section — never against a collection,
|
|
27
|
+
// element, or page that happens to share the string, and never against a
|
|
28
|
+
// second shared item of the same name.
|
|
29
|
+
const sharedSeen = new Set();
|
|
30
|
+
for (const item of config.registry.shared ?? []) {
|
|
31
|
+
const ownSection = item.name === item.section && sectionNames.has(item.name);
|
|
32
|
+
if (item.name === RESERVED_NAME)
|
|
33
|
+
throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
|
|
34
|
+
if (sharedSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
|
|
35
|
+
throw new Error(`smoodly: duplicate registered name "${item.name}".`);
|
|
36
|
+
}
|
|
37
|
+
sharedSeen.add(item.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A visual item renders through a registered section. */
|
|
41
|
+
function assertSharedSections(config) {
|
|
42
|
+
const sections = new Set((config.registry.sections ?? []).map((s) => s.schema.name));
|
|
43
|
+
for (const item of config.registry.shared ?? []) {
|
|
44
|
+
if (item.section !== undefined && !sections.has(item.section)) {
|
|
45
|
+
throw new Error(`smoodly: shared item "${item.name}" renders section "${item.section}", which is not registered.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
15
48
|
}
|
|
16
49
|
/** Every root-level segment a registration claims, per locale: fixed page
|
|
17
50
|
* slugs and collection paths. Two claims on one (locale, segment) are a
|
|
@@ -91,6 +124,7 @@ function assertLocales(config) {
|
|
|
91
124
|
export function defineConfig(config) {
|
|
92
125
|
assertLocales(config);
|
|
93
126
|
assertUniqueNames(config);
|
|
127
|
+
assertSharedSections(config);
|
|
94
128
|
assertRootSegments(config);
|
|
95
129
|
const depth = config.pages?.tree?.depth ?? 1;
|
|
96
130
|
if (!Number.isInteger(depth) || depth < 1)
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
|
+
import type { SharedSchema } from "./shared.ts";
|
|
2
3
|
import { type RefEdge } from "./refs.ts";
|
|
3
4
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
4
5
|
import { type PathIndex } from "./path-index.ts";
|
|
@@ -61,6 +62,7 @@ export type AddEntryLocaleOptions = {
|
|
|
61
62
|
export type EntryStoreOptions = {
|
|
62
63
|
paths?: PathIndex;
|
|
63
64
|
locales?: LocaleSet;
|
|
65
|
+
shared?: SharedSchema[];
|
|
64
66
|
};
|
|
65
67
|
export interface EntryStore {
|
|
66
68
|
/** Creates the node and ONE locale row from the merged `fields`: shared
|
|
@@ -118,6 +120,8 @@ export interface EntryStore {
|
|
|
118
120
|
pathsOf(collection: string, id: string): Promise<Record<string, string>>;
|
|
119
121
|
}
|
|
120
122
|
export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
|
|
123
|
+
/** A shared item is a collection with exactly one entry (spec 2026-09-07). */
|
|
124
|
+
export declare const isSharedSchema: (schema: CollectionSchema<any>) => boolean;
|
|
121
125
|
export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
|
|
122
126
|
export declare const entryErrors: {
|
|
123
127
|
notFound: (collection: string, id: string) => Error;
|
|
@@ -135,6 +139,10 @@ export declare const entryErrors: {
|
|
|
135
139
|
lastLocale: (id: string) => Error;
|
|
136
140
|
localeInUse: (locale: string, titles: string[]) => Error;
|
|
137
141
|
moveLocales: (locale: string) => Error;
|
|
142
|
+
singleton: (name: string) => Error;
|
|
143
|
+
owned: (name: string, verb: string) => Error;
|
|
144
|
+
noSlug: (name: string) => Error;
|
|
145
|
+
everyLocale: (name: string) => Error;
|
|
138
146
|
};
|
|
139
147
|
/** The keys a locale row owns: every field with .localized(), except
|
|
140
148
|
* `slug`, which is the row's own column (a segment is an address, per
|
|
@@ -174,7 +182,6 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
|
|
|
174
182
|
*/
|
|
175
183
|
export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
|
|
176
184
|
export declare class MemoryEntryStore implements EntryStore {
|
|
177
|
-
private collections;
|
|
178
185
|
private refs;
|
|
179
186
|
private entries;
|
|
180
187
|
private versions;
|
|
@@ -184,6 +191,7 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
184
191
|
private order;
|
|
185
192
|
private paths;
|
|
186
193
|
private locales;
|
|
194
|
+
private schemas;
|
|
187
195
|
constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
|
|
188
196
|
private schema;
|
|
189
197
|
private row;
|
package/dist/entry-store.js
CHANGED
|
@@ -13,6 +13,8 @@ import { collectionDepth, collectionOrder } from "./collections.js";
|
|
|
13
13
|
import { collectEntryRefs } from "./refs.js";
|
|
14
14
|
import { MemoryRefIndex } from "./ref-index.js";
|
|
15
15
|
import { MemoryPathIndex } from "./path-index.js";
|
|
16
|
+
import { sharedEntryId } from "./shared-id.js";
|
|
17
|
+
import { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
16
18
|
import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
|
|
17
19
|
export function schemaOf(collections, name) {
|
|
18
20
|
const schema = collections.find((c) => c.name === name);
|
|
@@ -20,6 +22,8 @@ export function schemaOf(collections, name) {
|
|
|
20
22
|
throw new Error(`smoodly: no collection named "${name}".`);
|
|
21
23
|
return schema;
|
|
22
24
|
}
|
|
25
|
+
/** A shared item is a collection with exactly one entry (spec 2026-09-07). */
|
|
26
|
+
export const isSharedSchema = (schema) => schema.kind === "shared";
|
|
23
27
|
let counter = 0;
|
|
24
28
|
const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
|
|
25
29
|
export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
|
|
@@ -39,6 +43,11 @@ export const entryErrors = {
|
|
|
39
43
|
lastLocale: (id) => new Error(`smoodly: entry "${id}" must exist in at least one locale — delete the entry instead.`),
|
|
40
44
|
localeInUse: (locale, titles) => new Error(`smoodly: child entries still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
|
|
41
45
|
moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the entry cannot move under it.`),
|
|
46
|
+
// Shared items (spec 2026-09-07 §3): one row, no URL, every locale, code-owned.
|
|
47
|
+
singleton: (name) => new Error(`smoodly: shared item "${name}" already exists — it is a singleton.`),
|
|
48
|
+
owned: (name, verb) => new Error(`smoodly: shared item "${name}" is owned by code — ${verb}.`),
|
|
49
|
+
noSlug: (name) => new Error(`smoodly: shared item "${name}" has no URL — it cannot take a slug.`),
|
|
50
|
+
everyLocale: (name) => new Error(`smoodly: shared item "${name}" exists in every locale — it cannot be removed from one.`),
|
|
42
51
|
};
|
|
43
52
|
/** The keys a locale row owns: every field with .localized(), except
|
|
44
53
|
* `slug`, which is the row's own column (a segment is an address, per
|
|
@@ -128,7 +137,6 @@ export class MemoryEntryStore {
|
|
|
128
137
|
constructor(collections,
|
|
129
138
|
// pass the SAME index to MemoryPageStore so cross-kind questions work
|
|
130
139
|
refs = new MemoryRefIndex(), options = {}) {
|
|
131
|
-
this.collections = collections;
|
|
132
140
|
this.refs = refs;
|
|
133
141
|
this.entries = new Map();
|
|
134
142
|
this.versions = new Map();
|
|
@@ -139,9 +147,10 @@ export class MemoryEntryStore {
|
|
|
139
147
|
this.byId = (id) => this.entries.get(id);
|
|
140
148
|
this.paths = options.paths ?? new MemoryPathIndex();
|
|
141
149
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
150
|
+
this.schemas = [...collections, ...(options.shared ?? [])];
|
|
142
151
|
}
|
|
143
152
|
schema(collection) {
|
|
144
|
-
return schemaOf(this.
|
|
153
|
+
return schemaOf(this.schemas, collection);
|
|
145
154
|
}
|
|
146
155
|
row(collection, id) {
|
|
147
156
|
const entry = this.entries.get(id);
|
|
@@ -253,13 +262,23 @@ export class MemoryEntryStore {
|
|
|
253
262
|
const schema = this.schema(collection);
|
|
254
263
|
assertSupportedLocale(options.locale, this.locales);
|
|
255
264
|
const parentId = options.parentId ?? null;
|
|
265
|
+
if (isSharedSchema(schema)) {
|
|
266
|
+
if ([...this.entries.values()].some((e) => e.collection === collection))
|
|
267
|
+
throw entryErrors.singleton(collection);
|
|
268
|
+
if (slugOf(fields) !== null)
|
|
269
|
+
throw entryErrors.noSlug(collection);
|
|
270
|
+
}
|
|
256
271
|
const parent = this.assertParent(collection, parentId, 1);
|
|
257
272
|
this.assertParentHasLocale(parent, options.locale);
|
|
258
273
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
259
274
|
this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
260
275
|
const now = Date.now();
|
|
261
276
|
const entry = {
|
|
262
|
-
id
|
|
277
|
+
// A shared item's id is derived, not minted (spec 2026-09-07 §10):
|
|
278
|
+
// the same uuid v5 the Supabase adapter computes, so self-host and
|
|
279
|
+
// cloud agree on the row a materialization would create.
|
|
280
|
+
id: isSharedSchema(schema) ? await sharedEntryId(DEFAULT_SPACE_ID, collection) : uid("entry"),
|
|
281
|
+
collection, parentId, fields: shared,
|
|
263
282
|
locales: {
|
|
264
283
|
[options.locale]: { slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now },
|
|
265
284
|
},
|
|
@@ -322,6 +341,8 @@ export class MemoryEntryStore {
|
|
|
322
341
|
const entry = this.must(collection, id);
|
|
323
342
|
const row = entryLocaleRow(entry, locale);
|
|
324
343
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
344
|
+
if (isSharedSchema(schema) && slug !== null)
|
|
345
|
+
throw entryErrors.noSlug(collection);
|
|
325
346
|
this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
|
|
326
347
|
const before = { fields: entry.fields, row: { ...row } };
|
|
327
348
|
entry.fields = shared;
|
|
@@ -387,6 +408,8 @@ export class MemoryEntryStore {
|
|
|
387
408
|
}
|
|
388
409
|
async removeLocale(collection, id, locale) {
|
|
389
410
|
const schema = this.schema(collection);
|
|
411
|
+
if (isSharedSchema(schema))
|
|
412
|
+
throw entryErrors.everyLocale(collection);
|
|
390
413
|
const entry = this.must(collection, id);
|
|
391
414
|
const row = entryLocaleRow(entry, locale);
|
|
392
415
|
if (Object.keys(entry.locales).length === 1)
|
|
@@ -417,6 +440,8 @@ export class MemoryEntryStore {
|
|
|
417
440
|
.map((v) => structuredClone(v));
|
|
418
441
|
}
|
|
419
442
|
async move(collection, id, to) {
|
|
443
|
+
if (isSharedSchema(this.schema(collection)))
|
|
444
|
+
throw entryErrors.owned(collection, "it has no tree position");
|
|
420
445
|
const entry = this.must(collection, id);
|
|
421
446
|
const subtree = this.subtree(entry);
|
|
422
447
|
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
@@ -454,6 +479,8 @@ export class MemoryEntryStore {
|
|
|
454
479
|
const entry = this.row(collection, id);
|
|
455
480
|
if (!entry)
|
|
456
481
|
return;
|
|
482
|
+
if (isSharedSchema(this.schema(collection)))
|
|
483
|
+
throw entryErrors.owned(collection, "remove the registration to remove it");
|
|
457
484
|
if ([...this.entries.values()].some((e) => e.parentId === id))
|
|
458
485
|
throw entryErrors.hasChildren(collection, id);
|
|
459
486
|
const usage = await this.referencesTo(collection, id);
|
|
@@ -467,7 +494,8 @@ export class MemoryEntryStore {
|
|
|
467
494
|
this.refs.remove({ sourceKind: "entry", sourceId: id });
|
|
468
495
|
}
|
|
469
496
|
async reorder(collection, orderedIds) {
|
|
470
|
-
this.schema(collection)
|
|
497
|
+
if (isSharedSchema(this.schema(collection)))
|
|
498
|
+
throw entryErrors.owned(collection, "it has no order");
|
|
471
499
|
orderedIds.forEach((id, i) => {
|
|
472
500
|
const entry = this.row(collection, id);
|
|
473
501
|
if (entry)
|
package/dist/fields.d.ts
CHANGED
|
@@ -31,6 +31,15 @@ export type BuilderFlags = {
|
|
|
31
31
|
optional?: true;
|
|
32
32
|
localized?: true;
|
|
33
33
|
};
|
|
34
|
+
/** A builder whose flags carry `localized: true` becomes `never` here, so
|
|
35
|
+
* `f.list(f.text().localized())` and `f.object({ a: f.text().localized() })`
|
|
36
|
+
* are type errors: localization is whole-value on lists and objects
|
|
37
|
+
* (spec 2026-09-07, list field §2). */
|
|
38
|
+
type NotLocalized<B> = B extends {
|
|
39
|
+
__flags?: infer Fl;
|
|
40
|
+
} ? Fl extends {
|
|
41
|
+
localized: true;
|
|
42
|
+
} ? never : B : B;
|
|
34
43
|
export declare const f: {
|
|
35
44
|
text: () => FieldBuilder<string, {}>;
|
|
36
45
|
link: () => FieldBuilder<string, {}>;
|
|
@@ -54,7 +63,12 @@ export declare const f: {
|
|
|
54
63
|
}) => FieldBuilder<unknown, {}>;
|
|
55
64
|
dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
|
|
56
65
|
select: <const O extends readonly string[]>(options: O) => FieldBuilder<O[number], {}>;
|
|
57
|
-
object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
|
|
66
|
+
object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S & { [K in keyof S]: NotLocalized<S[K]>; }) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
|
|
67
|
+
/** A repeating item: any builder, but no list at any depth inside the item
|
|
68
|
+
* — a list in an object in a list is still a nested list on screen, and a
|
|
69
|
+
* 300px sidebar cannot show one legibly (spec 2026-09-07, list field §1).
|
|
70
|
+
* `.min`/`.max` count items. */
|
|
71
|
+
list: <B extends FieldBuilder<any, any>>(item: B & NotLocalized<B>) => FieldBuilder<ValueOf<B>[], {}>;
|
|
58
72
|
ref: <T>(target: () => T) => FieldBuilder<Resolved<T>, {}>;
|
|
59
73
|
refList: <T>(target: () => T) => FieldBuilder<Resolved<T>[], {}>;
|
|
60
74
|
};
|
|
@@ -65,3 +79,4 @@ export type ValueOf<B> = B extends FieldBuilder<infer V, infer Flags> ? Flags ex
|
|
|
65
79
|
export type Resolved<T> = T extends {
|
|
66
80
|
__entry?: infer E;
|
|
67
81
|
} ? E : Record<string, unknown>;
|
|
82
|
+
export {};
|
package/dist/fields.js
CHANGED
|
@@ -20,6 +20,35 @@ function builder(descriptor) {
|
|
|
20
20
|
tab: (tab) => chain({ tab }),
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
const describePath = (path) => (path === "" ? "the item" : `"${path}"`);
|
|
24
|
+
/** First path at which `matches` is true of the descriptor, walking into
|
|
25
|
+
* object subfields and a list's item, or null. */
|
|
26
|
+
function findMatch(d, matches, path = "") {
|
|
27
|
+
if (matches(d))
|
|
28
|
+
return path;
|
|
29
|
+
if (d.type === "object") {
|
|
30
|
+
for (const [k, sub] of Object.entries(d.fields)) {
|
|
31
|
+
const hit = findMatch(sub, matches, path ? `${path}.${k}` : k);
|
|
32
|
+
if (hit !== null)
|
|
33
|
+
return hit;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (d.type === "list")
|
|
37
|
+
return findMatch(d.item, matches, path);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** Refs are addressed by top-level key everywhere (resolver, refs index,
|
|
41
|
+
* admin); `.localized()` decides storage per top-level field. Neither may
|
|
42
|
+
* hide inside a container. */
|
|
43
|
+
function assertContainerItem(kind, d, path = "") {
|
|
44
|
+
const ref = findMatch(d, (sub) => sub.type === "ref" || sub.type === "refList", path);
|
|
45
|
+
if (ref !== null)
|
|
46
|
+
throw new Error(`smoodly: ${kind} cannot hold a ref (at ${describePath(ref)}) — refs live at the top level of fields.`);
|
|
47
|
+
const loc = findMatch(d, (sub) => sub.localized === true, path);
|
|
48
|
+
if (loc !== null) {
|
|
49
|
+
throw new Error(`smoodly: .localized() inside ${kind} (at ${describePath(loc)}) — localize the ${kind === "f.list" ? "list" : "object"} itself.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
23
52
|
export const f = {
|
|
24
53
|
text: () => builder({ type: "text" }),
|
|
25
54
|
link: () => builder({ type: "link" }),
|
|
@@ -32,10 +61,26 @@ export const f = {
|
|
|
32
61
|
richtext: (opts) => builder({ type: "richtext", ...(opts?.toolset !== undefined ? { toolset: opts.toolset } : {}) }),
|
|
33
62
|
dataAttributes: () => builder({ type: "dataAttributes" }),
|
|
34
63
|
select: (options) => builder({ type: "select", options: [...options] }),
|
|
35
|
-
object: (shape) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
object: (shape) => {
|
|
65
|
+
for (const [k, v] of Object.entries(shape))
|
|
66
|
+
assertContainerItem("f.object", v.descriptor, k);
|
|
67
|
+
return builder({
|
|
68
|
+
type: "object",
|
|
69
|
+
fields: Object.fromEntries(Object.entries(shape).map(([k, v]) => [k, v.descriptor])),
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
/** A repeating item: any builder, but no list at any depth inside the item
|
|
73
|
+
* — a list in an object in a list is still a nested list on screen, and a
|
|
74
|
+
* 300px sidebar cannot show one legibly (spec 2026-09-07, list field §1).
|
|
75
|
+
* `.min`/`.max` count items. */
|
|
76
|
+
list: (item) => {
|
|
77
|
+
const d = item.descriptor;
|
|
78
|
+
const nested = findMatch(d, (sub) => sub.type === "list");
|
|
79
|
+
if (nested !== null)
|
|
80
|
+
throw new Error(`smoodly: f.list cannot hold another list (at ${describePath(nested)}) — nested lists are not supported.`);
|
|
81
|
+
assertContainerItem("f.list", d);
|
|
82
|
+
return builder({ type: "list", item: d });
|
|
83
|
+
},
|
|
39
84
|
ref: (target) => builder({ type: "ref", target }),
|
|
40
85
|
refList: (target) => builder({ type: "refList", target }),
|
|
41
86
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { collection, toolset } from "./collections.ts";
|
|
|
3
3
|
import { Section } from "./section-wrapper.tsx";
|
|
4
4
|
import { page } from "./render.tsx";
|
|
5
5
|
import { entryPage } from "./entry-page.tsx";
|
|
6
|
+
import { shared } from "./shared.ts";
|
|
6
7
|
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.tsx";
|
|
7
8
|
export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageLink, PageViewProps } from "./render.tsx";
|
|
9
|
+
export { RichText } from "./richtext-render.tsx";
|
|
8
10
|
export type { PageSchema } from "./page.ts";
|
|
9
11
|
export { fixedSlug } from "./page.ts";
|
|
10
12
|
export type { EntryPageSchema, PairedEntryPage, EntryPageProps } from "./entry-page.tsx";
|
|
@@ -24,7 +26,7 @@ export { SupabaseEntryStore } from "./supabase-entry-store.ts";
|
|
|
24
26
|
export { SupabasePathIndex } from "./supabase-path-index.ts";
|
|
25
27
|
export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, } from "./entry-store.ts";
|
|
26
28
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.ts";
|
|
27
|
-
export { affectedTargets, pageTag, entryTag } from "./revalidate.ts";
|
|
29
|
+
export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.ts";
|
|
28
30
|
export type { AffectedTargets } from "./revalidate.ts";
|
|
29
31
|
export { resolveTree, resolveFields } from "./resolve.ts";
|
|
30
32
|
export type { EntryFetcher, ResolveFieldsOptions } from "./resolve.ts";
|
|
@@ -40,6 +42,10 @@ export type { Props, PairedComponent } from "./pairing.tsx";
|
|
|
40
42
|
export type { CollectionSchema, Toolset, EntryOrder } from "./collections.ts";
|
|
41
43
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.ts";
|
|
42
44
|
export type { ZonePolicy } from "./zones.ts";
|
|
45
|
+
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.ts";
|
|
46
|
+
export { sharedEntryId } from "./shared-id.ts";
|
|
47
|
+
export { DEFAULT_SPACE_ID } from "./sql-space.ts";
|
|
48
|
+
export type { SharedSchema, SharedLink } from "./shared.ts";
|
|
43
49
|
export declare const smoodly: {
|
|
44
50
|
schema: {
|
|
45
51
|
page: typeof import("./page.ts").pageSchema;
|
|
@@ -110,6 +116,7 @@ export declare const smoodly: {
|
|
|
110
116
|
entryPage: typeof entryPage;
|
|
111
117
|
collection: typeof collection;
|
|
112
118
|
toolset: typeof toolset;
|
|
119
|
+
shared: typeof shared;
|
|
113
120
|
Section: typeof Section;
|
|
114
121
|
};
|
|
115
122
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,9 @@ import { collection, toolset } from "./collections.js";
|
|
|
4
4
|
import { Section } from "./section-wrapper.js";
|
|
5
5
|
import { page } from "./render.js";
|
|
6
6
|
import { entryPage } from "./entry-page.js";
|
|
7
|
+
import { shared } from "./shared.js";
|
|
7
8
|
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.js";
|
|
9
|
+
export { RichText } from "./richtext-render.js";
|
|
8
10
|
export { fixedSlug } from "./page.js";
|
|
9
11
|
export { f } from "./fields.js";
|
|
10
12
|
export { z } from "./zones.js";
|
|
@@ -19,12 +21,15 @@ export { MemoryEntryStore } from "./entry-store.js";
|
|
|
19
21
|
export { SupabaseEntryStore } from "./supabase-entry-store.js";
|
|
20
22
|
export { SupabasePathIndex } from "./supabase-path-index.js";
|
|
21
23
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.js";
|
|
22
|
-
export { affectedTargets, pageTag, entryTag } from "./revalidate.js";
|
|
24
|
+
export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.js";
|
|
23
25
|
export { resolveTree, resolveFields } from "./resolve.js";
|
|
24
26
|
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
|
|
25
27
|
export { fixedPathFor } from "./localize.js";
|
|
26
28
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
27
29
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
30
|
+
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.js";
|
|
31
|
+
export { sharedEntryId } from "./shared-id.js";
|
|
32
|
+
export { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
28
33
|
export const smoodly = {
|
|
29
34
|
schema,
|
|
30
35
|
section,
|
|
@@ -33,6 +38,7 @@ export const smoodly = {
|
|
|
33
38
|
entryPage,
|
|
34
39
|
collection,
|
|
35
40
|
toolset,
|
|
41
|
+
shared,
|
|
36
42
|
Section,
|
|
37
43
|
};
|
|
38
44
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
|
|
@@ -2,6 +2,7 @@ import type { ComponentType, ReactElement } from "react";
|
|
|
2
2
|
import type { Metadata } from "next";
|
|
3
3
|
import type { Descriptor } from "../fields.ts";
|
|
4
4
|
import { type PairedPage } from "../render.tsx";
|
|
5
|
+
import type { SharedSchema } from "../shared.ts";
|
|
5
6
|
import { type SmoodlySite } from "../site.ts";
|
|
6
7
|
/** A registered section, as renderPage's registry needs it. */
|
|
7
8
|
type SectionEntry = ComponentType<any> & {
|
|
@@ -31,6 +32,9 @@ export type PathRenderOptions = {
|
|
|
31
32
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
32
33
|
};
|
|
33
34
|
export declare function createPageRenderer(options: PageRendererOptions): {
|
|
35
|
+
getSmoodlyShared: <V>(item: SharedSchema<V>, opts?: {
|
|
36
|
+
locale?: string;
|
|
37
|
+
}) => Promise<V | null>;
|
|
34
38
|
renderSmoodlyPath: (segments: string[], opts?: PathRenderOptions) => Promise<ReactElement<unknown, string | import("react").JSXElementConstructor<any>>>;
|
|
35
39
|
smoodlyMetadata: (segments: string[], opts?: {
|
|
36
40
|
locale?: string;
|
|
@@ -7,7 +7,7 @@ import { EditorBridge } from "../admin/next/bridge.js";
|
|
|
7
7
|
import { fixedPathFor } from "../localize.js";
|
|
8
8
|
import { joinPath } from "../paths.js";
|
|
9
9
|
import { renderPage } from "../render.js";
|
|
10
|
-
import { entryTag, pageTag } from "../revalidate.js";
|
|
10
|
+
import { entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
11
11
|
import { pageContextOf } from "../site.js";
|
|
12
12
|
import { metadataFrom } from "./meta.js";
|
|
13
13
|
export function createPageRenderer(options) {
|
|
@@ -82,7 +82,20 @@ export function createPageRenderer(options) {
|
|
|
82
82
|
* error surfaces as a rejected promise like every other failure. */
|
|
83
83
|
const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
|
|
84
84
|
const literal = (segments) => () => joinPath(segments);
|
|
85
|
+
/** A shared item for a layout: cached per (item, locale) under the
|
|
86
|
+
* shared tag outside draft mode, the live draft inside it. */
|
|
87
|
+
async function getSmoodlyShared(item, opts) {
|
|
88
|
+
await connection();
|
|
89
|
+
const locale = opts?.locale ?? site.locales.default;
|
|
90
|
+
const { isEnabled: draft } = await draftMode();
|
|
91
|
+
if (draft)
|
|
92
|
+
return site.getShared(item, { locale, draft: true });
|
|
93
|
+
return unstable_cache(() => site.getShared(item, { locale }), ["smoodly-shared", item.name, locale], {
|
|
94
|
+
tags: [sharedTag(item.name)],
|
|
95
|
+
})();
|
|
96
|
+
}
|
|
85
97
|
return {
|
|
98
|
+
getSmoodlyShared,
|
|
86
99
|
renderSmoodlyPath: (segments, opts) => page(literal(segments), opts, any),
|
|
87
100
|
smoodlyMetadata: (segments, opts) => metadata(literal(segments), opts, any),
|
|
88
101
|
/** A fixed registration rendered from the developer's own route file. */
|
package/dist/page.js
CHANGED
|
@@ -10,7 +10,11 @@ function normalizePolicy(p) {
|
|
|
10
10
|
case "freeform":
|
|
11
11
|
return { kind: "freeform", allow: p.allow, rows: p.rows };
|
|
12
12
|
case "locked":
|
|
13
|
-
return {
|
|
13
|
+
return {
|
|
14
|
+
kind: "locked",
|
|
15
|
+
component: p.component,
|
|
16
|
+
...(typeof p.shared === "string" ? { shared: p.shared } : {}),
|
|
17
|
+
};
|
|
14
18
|
default:
|
|
15
19
|
throw new Error(`smoodly: unknown zone policy kind "${String(p.kind)}".`);
|
|
16
20
|
}
|
package/dist/refs.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// and never the source of truth. Stores rewrite a source's edges on
|
|
5
5
|
// every save; `refs` answers only the REVERSE question (who depends on
|
|
6
6
|
// this?) — forward resolution stays resolveTree's job.
|
|
7
|
+
import { sharedLink } from "./shared.js";
|
|
7
8
|
function targetOf(d) {
|
|
8
9
|
const t = d.target?.();
|
|
9
10
|
if (!t?.name)
|
|
@@ -24,6 +25,11 @@ function collectFrom(values, descriptors, out) {
|
|
|
24
25
|
out.push({ field, targetCollection: targetOf(d), targetId: id });
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
else if (d.type === "object" || d.type === "list") {
|
|
29
|
+
// Deliberate: refs are top-level only — f.list/f.object refuse them at
|
|
30
|
+
// build time (spec 2026-09-07, list field §1), so there is nothing to walk.
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
35
|
export function collectEntryRefs(fields, schema) {
|
|
@@ -39,6 +45,13 @@ export function collectTreeRefs(tree, sections) {
|
|
|
39
45
|
const out = [];
|
|
40
46
|
for (const nodes of Object.values(tree.zones ?? {})) {
|
|
41
47
|
for (const node of nodes) {
|
|
48
|
+
// A placement (spec 2026-09-07 §5): one edge, keyed by the node id
|
|
49
|
+
// so two placements of one item on one page stay distinct rows.
|
|
50
|
+
const link = sharedLink(node);
|
|
51
|
+
if (link) {
|
|
52
|
+
out.push({ field: node.id, targetCollection: link.item, targetId: link.ref });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
42
55
|
const schema = byName.get(node.type);
|
|
43
56
|
if (!schema)
|
|
44
57
|
continue; // fail-soft, same as renderPage
|
package/dist/resolve.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 { PageTree } from "./render.tsx";
|
|
4
|
+
import { type SharedSchema } from "./shared.ts";
|
|
4
5
|
export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
|
|
5
6
|
type SchemaLike = {
|
|
6
7
|
name: string;
|
|
@@ -15,6 +16,8 @@ type ResolveOptions = {
|
|
|
15
16
|
})[];
|
|
16
17
|
collections: CollectionSchema<any>[];
|
|
17
18
|
fetch: EntryFetcher;
|
|
19
|
+
/** The registered shared items a placement node may name (spec 2026-09-07 §4). */
|
|
20
|
+
shared?: SharedSchema[];
|
|
18
21
|
/** Optional safety valve on chain length; unlimited by default. */
|
|
19
22
|
maxDepth?: number;
|
|
20
23
|
};
|
package/dist/resolve.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Cycles are cut per path — a ref back to an entry already on its own
|
|
7
7
|
// chain gets a shallow copy (refs as ids), so traversal terminates by
|
|
8
8
|
// construction and the output stays JSON-serializable.
|
|
9
|
+
import { sharedLink, SHARED_NODE_TYPE, STYLES_KEY } from "./shared.js";
|
|
9
10
|
function targetOf(d) {
|
|
10
11
|
const t = d.target?.();
|
|
11
12
|
if (!t?.name)
|
|
@@ -27,6 +28,7 @@ export async function resolveTree(tree, options) {
|
|
|
27
28
|
return [schema.name, schema];
|
|
28
29
|
}));
|
|
29
30
|
const resolved = structuredClone(tree);
|
|
31
|
+
await rewritePlacements(resolved, options.shared ?? [], options.fetch);
|
|
30
32
|
const tasks = [];
|
|
31
33
|
for (const nodes of Object.values(resolved.zones ?? {})) {
|
|
32
34
|
for (const node of nodes) {
|
|
@@ -39,6 +41,42 @@ export async function resolveTree(tree, options) {
|
|
|
39
41
|
await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
|
|
40
42
|
return resolved;
|
|
41
43
|
}
|
|
44
|
+
/** Placements first (spec 2026-09-07 §4): fetch every linked item in one
|
|
45
|
+
* call per item, rewrite each node into the item's section node — the
|
|
46
|
+
* placement's id stays, so the canvas selects it — and drop what the
|
|
47
|
+
* site cannot render: an unpublished or dangling row, an object item
|
|
48
|
+
* (no view), an unknown item. The normal pass then hydrates the
|
|
49
|
+
* rewritten nodes' own refs. */
|
|
50
|
+
async function rewritePlacements(tree, shared, fetch) {
|
|
51
|
+
const byName = new Map(shared.map((s) => [s.name, s]));
|
|
52
|
+
const wanted = new Map();
|
|
53
|
+
for (const nodes of Object.values(tree.zones ?? {})) {
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
const link = sharedLink(node);
|
|
56
|
+
if (!link || !byName.get(link.item)?.section)
|
|
57
|
+
continue;
|
|
58
|
+
const ids = wanted.get(link.item) ?? new Set();
|
|
59
|
+
ids.add(link.ref);
|
|
60
|
+
wanted.set(link.item, ids);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const fetched = new Map();
|
|
64
|
+
await Promise.all([...wanted].map(async ([item, ids]) => fetched.set(item, await fetch(item, [...ids]))));
|
|
65
|
+
for (const [zone, nodes] of Object.entries(tree.zones ?? {})) {
|
|
66
|
+
tree.zones[zone] = nodes.flatMap((node) => {
|
|
67
|
+
const link = sharedLink(node);
|
|
68
|
+
if (!link)
|
|
69
|
+
return node.type === SHARED_NODE_TYPE ? [] : [node];
|
|
70
|
+
const schema = byName.get(link.item);
|
|
71
|
+
const row = fetched.get(link.item)?.[link.ref];
|
|
72
|
+
if (!schema?.section || !row)
|
|
73
|
+
return [];
|
|
74
|
+
const { [STYLES_KEY]: styles, ...fields } = structuredClone(row);
|
|
75
|
+
const isObject = styles !== null && typeof styles === "object";
|
|
76
|
+
return [{ id: node.id, type: schema.section, fields, ...(isObject ? { styles: styles } : {}) }];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
42
80
|
/** One entry's fields, refs hydrated — the entry page's counterpart of
|
|
43
81
|
* resolveTree. Same rules, same batching, same cycle cut. */
|
|
44
82
|
export async function resolveFields(fields, descriptors, options) {
|
|
@@ -136,5 +174,8 @@ function collectTasks(values, descriptors, out) {
|
|
|
136
174
|
else if (d.type === "refList" && Array.isArray(value)) {
|
|
137
175
|
out.push({ holder: values, key, collection: targetOf(d), id: value, isList: true, mode: modeOf(d), ancestors: [] });
|
|
138
176
|
}
|
|
177
|
+
else if (d.type === "object" || d.type === "list") {
|
|
178
|
+
continue; // refs are top-level only; containers hold none (spec 2026-09-07)
|
|
179
|
+
}
|
|
139
180
|
}
|
|
140
181
|
}
|
package/dist/revalidate.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type AffectedTargets = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const pageTag: (id: string) => string;
|
|
10
10
|
export declare const entryTag: (id: string) => string;
|
|
11
|
+
/** A shared item's cache unit, by NAME (a singleton has one row per
|
|
12
|
+
* locale, and code owns the name): the Next glue's getSmoodlyShared
|
|
13
|
+
* caches under it; ops.shared expires it. */
|
|
14
|
+
export declare const sharedTag: (name: string) => string;
|
|
11
15
|
export declare function affectedTargets(stores: {
|
|
12
16
|
pages: PageStore;
|
|
13
17
|
entries: EntryStore;
|
package/dist/revalidate.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// change (spec 2026-09-05 §5).
|
|
8
8
|
export const pageTag = (id) => `page:${id}`;
|
|
9
9
|
export const entryTag = (id) => `entry:${id}`;
|
|
10
|
+
/** A shared item's cache unit, by NAME (a singleton has one row per
|
|
11
|
+
* locale, and code owns the name): the Next glue's getSmoodlyShared
|
|
12
|
+
* caches under it; ops.shared expires it. */
|
|
13
|
+
export const sharedTag = (name) => `shared:${name}`;
|
|
10
14
|
export async function affectedTargets(stores, entryId, options = {}) {
|
|
11
15
|
const maxDepth = options.maxDepth ?? Infinity;
|
|
12
16
|
const seen = new Set([entryId]);
|