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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** An empty item: an object of its subfields' defaults, else the default. */
|
|
2
|
+
export function emptyValue(d) {
|
|
3
|
+
if (d.type === "object") {
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const [k, sub] of Object.entries(d.fields ?? {})) {
|
|
6
|
+
const v = emptyValue(sub);
|
|
7
|
+
if (v !== undefined)
|
|
8
|
+
out[k] = v;
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
return d.default;
|
|
13
|
+
}
|
|
14
|
+
export function addItem(items, list) {
|
|
15
|
+
if (typeof list.max === "number" && items.length >= list.max)
|
|
16
|
+
return items;
|
|
17
|
+
return [...items, list.item ? emptyValue(list.item) : undefined];
|
|
18
|
+
}
|
|
19
|
+
export function removeItem(items, index) {
|
|
20
|
+
return items.filter((_, i) => i !== index);
|
|
21
|
+
}
|
|
22
|
+
export function moveItem(items, from, to) {
|
|
23
|
+
if (to < 0 || to >= items.length || from === to)
|
|
24
|
+
return items;
|
|
25
|
+
const next = [...items];
|
|
26
|
+
const [moved] = next.splice(from, 1);
|
|
27
|
+
next.splice(to, 0, moved);
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
/** Permutes a set of item indices (e.g. collapse state) for the same move
|
|
31
|
+
* `moveItem` just applied: the index at `from` becomes `to`; indices strictly
|
|
32
|
+
* between the two shift by one toward `from`; every other index is unchanged. */
|
|
33
|
+
export function moveIndex(set, from, to) {
|
|
34
|
+
const next = new Set();
|
|
35
|
+
for (const i of set) {
|
|
36
|
+
if (i === from)
|
|
37
|
+
next.add(to);
|
|
38
|
+
else if (from < to && i > from && i <= to)
|
|
39
|
+
next.add(i - 1);
|
|
40
|
+
else if (to < from && i >= to && i < from)
|
|
41
|
+
next.add(i + 1);
|
|
42
|
+
else
|
|
43
|
+
next.add(i);
|
|
44
|
+
}
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
/** Permutes a set of item indices for a removal at `index`: drops it, and
|
|
48
|
+
* shifts every higher index down by one. */
|
|
49
|
+
export function removeIndex(set, index) {
|
|
50
|
+
const next = new Set();
|
|
51
|
+
for (const i of set) {
|
|
52
|
+
if (i === index)
|
|
53
|
+
continue;
|
|
54
|
+
next.add(i > index ? i - 1 : i);
|
|
55
|
+
}
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
59
|
+
/** The row header's text: the value, or its first non-empty string, or "Item N". */
|
|
60
|
+
export function itemSummary(value, index) {
|
|
61
|
+
if (nonEmpty(value))
|
|
62
|
+
return value;
|
|
63
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
64
|
+
const hit = Object.values(value).find(nonEmpty);
|
|
65
|
+
if (hit)
|
|
66
|
+
return hit;
|
|
67
|
+
}
|
|
68
|
+
return `Item ${index + 1}`;
|
|
69
|
+
}
|
|
70
|
+
/** A subfield change on an object value. `collapseEmpty` (an optional
|
|
71
|
+
* object) turns an object with no defined values into `undefined`. */
|
|
72
|
+
export function patchObject(obj, key, value, collapseEmpty) {
|
|
73
|
+
const next = { ...obj };
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
delete next[key];
|
|
76
|
+
else
|
|
77
|
+
next[key] = value;
|
|
78
|
+
if (collapseEmpty && Object.values(next).every((v) => v === undefined))
|
|
79
|
+
return undefined;
|
|
80
|
+
return next;
|
|
81
|
+
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult } from "./ops.ts";
|
|
1
|
+
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult } from "./ops.ts";
|
|
2
2
|
export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
|
|
3
3
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
|
|
4
|
+
export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";
|
|
4
5
|
export { makeClientOps } from "./client-ops.ts";
|
|
5
6
|
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
6
7
|
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
|
7
8
|
export { serializeAdminConfig } from "./serialize.ts";
|
|
8
|
-
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminField } from "./serialize.ts";
|
|
9
|
+
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
|
|
9
10
|
export * from "./tree-ops.ts";
|
|
10
11
|
export { validateFields, type FieldError } from "./validate.ts";
|
|
11
12
|
export { plainToRichtext, richtextToPlain } from "./richtext.ts";
|
package/dist/admin/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createAdminOps, runAdminOp } from "./ops-impl.js";
|
|
2
2
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
|
|
3
|
+
export { ensureSharedItems, initialSharedFields } from "./shared-items.js";
|
|
3
4
|
export { makeClientOps } from "./client-ops.js";
|
|
4
5
|
export { gateAdminOp } from "./auth.js";
|
|
5
6
|
export { supabaseAdminAuth } from "./supabase-auth.js";
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { entryFieldsIn } from "../entry-store.js";
|
|
2
|
-
import { affectedTargets, entryTag, pageTag } from "../revalidate.js";
|
|
2
|
+
import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
3
3
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
4
|
+
import { ensureSharedItems } from "./shared-items.js";
|
|
4
5
|
import { validateFields } from "./validate.js";
|
|
5
6
|
const ok = (data) => ({ ok: true, data });
|
|
6
7
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
@@ -43,6 +44,15 @@ const classify = (e) => {
|
|
|
43
44
|
}
|
|
44
45
|
if (message.includes("does not exist in locale"))
|
|
45
46
|
return err("not_found", message);
|
|
47
|
+
if (message.includes("cannot take a slug"))
|
|
48
|
+
return err("validation", message);
|
|
49
|
+
// Ahead of the generic "already exists" check below: the singleton
|
|
50
|
+
// refusal's message ("...already exists — it is a singleton.") contains
|
|
51
|
+
// "already exists" as a substring, so it must classify here first or it
|
|
52
|
+
// would be misread as a plain conflict.
|
|
53
|
+
if (message.includes("is owned by code") || message.includes("it is a singleton") || message.includes("exists in every locale")) {
|
|
54
|
+
return err("blocked", message);
|
|
55
|
+
}
|
|
46
56
|
if (message.includes("already exists"))
|
|
47
57
|
return err("conflict", message);
|
|
48
58
|
if (message.includes("is referenced by"))
|
|
@@ -75,6 +85,9 @@ export function createAdminOps(deps) {
|
|
|
75
85
|
const href = deps.href ?? ((path) => path);
|
|
76
86
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
77
87
|
const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
|
|
88
|
+
const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
|
|
89
|
+
/** One row per registration; null when materialization could not create it. */
|
|
90
|
+
const sharedRecord = async (name) => (await entries.list(name))[0] ?? null;
|
|
78
91
|
const fanOut = async (entryId) => {
|
|
79
92
|
if (!effects?.revalidate)
|
|
80
93
|
return;
|
|
@@ -83,6 +96,24 @@ export function createAdminOps(deps) {
|
|
|
83
96
|
effects.revalidate(entryTag(id));
|
|
84
97
|
for (const id of pageIds)
|
|
85
98
|
effects.revalidate(pageTag(id));
|
|
99
|
+
// A shared item's row is an entry like any other: when the item
|
|
100
|
+
// itself changes OR when something it REFERENCES does, the unit
|
|
101
|
+
// `getSmoodlyShared` caches under `shared:<name>` is stale too — and
|
|
102
|
+
// that unit is a layout, on every page. `affectedTargets` returns row
|
|
103
|
+
// ids, so resolve name → row id here. Once per fan-out, not memoized
|
|
104
|
+
// per ops instance: a row can be materialized between two calls, and
|
|
105
|
+
// an entry write is rare enough that one `entries.list` per
|
|
106
|
+
// registration beats reasoning about a stale cache. Last, so the
|
|
107
|
+
// existing entry-then-page order is untouched.
|
|
108
|
+
const registered = config.registry.shared ?? [];
|
|
109
|
+
if (registered.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
const touched = new Set(entryIds);
|
|
112
|
+
for (const item of registered) {
|
|
113
|
+
const row = (await entries.list(item.name))[0];
|
|
114
|
+
if (row && touched.has(row.id))
|
|
115
|
+
effects.revalidate(sharedTag(item.name));
|
|
116
|
+
}
|
|
86
117
|
};
|
|
87
118
|
/** Expire the cached units a tree change touched, in order, once each. */
|
|
88
119
|
const expirePages = (ids) => {
|
|
@@ -404,6 +435,58 @@ export function createAdminOps(deps) {
|
|
|
404
435
|
return ok(entry);
|
|
405
436
|
}),
|
|
406
437
|
},
|
|
438
|
+
shared: {
|
|
439
|
+
list: () => guard(async () => {
|
|
440
|
+
const records = await ensureSharedItems(entries, config);
|
|
441
|
+
return ok((config.registry.shared ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
|
|
442
|
+
}),
|
|
443
|
+
get: (name, locale) => guard(async () => {
|
|
444
|
+
const unsupported = supportedLocale(locale);
|
|
445
|
+
if (unsupported)
|
|
446
|
+
return unsupported;
|
|
447
|
+
const item = sharedItem(name);
|
|
448
|
+
if (!item)
|
|
449
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
450
|
+
await ensureSharedItems(entries, config);
|
|
451
|
+
const record = await sharedRecord(name);
|
|
452
|
+
if (!record?.locales[locale])
|
|
453
|
+
return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
|
|
454
|
+
const usage = await entries.referencesTo(name, record.id);
|
|
455
|
+
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
|
|
456
|
+
}),
|
|
457
|
+
save: (name, locale, fields) => guard(async () => {
|
|
458
|
+
const unsupported = supportedLocale(locale);
|
|
459
|
+
if (unsupported)
|
|
460
|
+
return unsupported;
|
|
461
|
+
const item = sharedItem(name);
|
|
462
|
+
if (!item)
|
|
463
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
464
|
+
const fieldErrors = validateFields(fields, item.fields);
|
|
465
|
+
if (fieldErrors.length > 0)
|
|
466
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
467
|
+
const record = await sharedRecord(name);
|
|
468
|
+
if (!record)
|
|
469
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
470
|
+
const saved = await entries.update(name, record.id, locale, fields);
|
|
471
|
+
// fanOut expires `shared:<name>` itself — this row IS the item's.
|
|
472
|
+
await fanOut(record.id);
|
|
473
|
+
return ok(saved);
|
|
474
|
+
}),
|
|
475
|
+
setStatus: (name, locale, status) => guard(async () => {
|
|
476
|
+
const unsupported = supportedLocale(locale);
|
|
477
|
+
if (unsupported)
|
|
478
|
+
return unsupported;
|
|
479
|
+
if (!sharedItem(name))
|
|
480
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
481
|
+
const record = await sharedRecord(name);
|
|
482
|
+
if (!record)
|
|
483
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
484
|
+
const changed = await entries.setStatus(name, record.id, locale, status);
|
|
485
|
+
// see save: the fan-out carries the item's own tag.
|
|
486
|
+
await fanOut(record.id);
|
|
487
|
+
return ok(changed);
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
407
490
|
preview: {
|
|
408
491
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
|
409
492
|
disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageRecord } from "../store.ts";
|
|
2
2
|
import type { PageTree } from "../render.tsx";
|
|
3
|
-
import type { EntryRecord } from "../entry-store.ts";
|
|
3
|
+
import type { EntryRecord, EntryUsage } from "../entry-store.ts";
|
|
4
4
|
import type { FieldError } from "./validate.ts";
|
|
5
5
|
export type OpError = {
|
|
6
6
|
ok: false;
|
|
@@ -56,6 +56,20 @@ export type EntryGetResult = {
|
|
|
56
56
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
57
57
|
parentLocales: string[];
|
|
58
58
|
};
|
|
59
|
+
export type SharedListItem = {
|
|
60
|
+
name: string;
|
|
61
|
+
record: EntryRecord;
|
|
62
|
+
};
|
|
63
|
+
export type SharedGetResult = {
|
|
64
|
+
name: string;
|
|
65
|
+
/** The node with ALL its locale rows (every supported one, by construction). */
|
|
66
|
+
record: EntryRecord;
|
|
67
|
+
locale: string;
|
|
68
|
+
/** That locale's merged draft fields — a visual item's styles under `$styles`. */
|
|
69
|
+
fields: Record<string, unknown>;
|
|
70
|
+
/** Who links it: "used on N pages". */
|
|
71
|
+
usage: EntryUsage[];
|
|
72
|
+
};
|
|
59
73
|
export type AdminOps = {
|
|
60
74
|
pages: {
|
|
61
75
|
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
@@ -105,6 +119,13 @@ export type AdminOps = {
|
|
|
105
119
|
/** Deletes the locale's row, versions and paths; fans out like a save. */
|
|
106
120
|
removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
107
121
|
};
|
|
122
|
+
shared: {
|
|
123
|
+
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
124
|
+
list(): Promise<OpResult<SharedListItem[]>>;
|
|
125
|
+
get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
|
|
126
|
+
save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
127
|
+
setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
128
|
+
};
|
|
108
129
|
preview: {
|
|
109
130
|
enable(): Promise<OpResult<null>>;
|
|
110
131
|
disable(): Promise<OpResult<null>>;
|
package/dist/admin/richtext.d.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
type
|
|
2
|
-
type:
|
|
3
|
-
|
|
1
|
+
export type RichtextNode = {
|
|
2
|
+
type: string;
|
|
3
|
+
attrs?: Record<string, unknown>;
|
|
4
|
+
content?: RichtextNode[];
|
|
5
|
+
text?: string;
|
|
6
|
+
marks?: {
|
|
7
|
+
type: string;
|
|
8
|
+
attrs?: Record<string, unknown>;
|
|
9
|
+
}[];
|
|
4
10
|
};
|
|
5
|
-
type
|
|
6
|
-
type: "paragraph";
|
|
7
|
-
content?: TextNode[];
|
|
8
|
-
};
|
|
9
|
-
export declare function richtextToPlain(doc: unknown): string;
|
|
10
|
-
export declare function plainToRichtext(text: string): {
|
|
11
|
+
export type RichtextDoc = {
|
|
11
12
|
type: "doc";
|
|
12
|
-
content:
|
|
13
|
+
content: RichtextNode[];
|
|
13
14
|
};
|
|
14
|
-
export
|
|
15
|
+
export declare function plainToRichtext(text: string): RichtextDoc;
|
|
16
|
+
export declare function richtextToPlain(doc: unknown): string;
|
package/dist/admin/richtext.js
CHANGED
|
@@ -1,17 +1,98 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.
|
|
1
|
+
const textOf = (s) => (s ? [{ type: "text", text: s }] : []);
|
|
2
|
+
function paragraph(lines) {
|
|
3
|
+
const content = [];
|
|
4
|
+
lines.forEach((line, i) => {
|
|
5
|
+
if (i > 0)
|
|
6
|
+
content.push({ type: "hardBreak" });
|
|
7
|
+
content.push(...textOf(line));
|
|
8
|
+
});
|
|
9
|
+
return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" };
|
|
8
10
|
}
|
|
11
|
+
const listItem = (s) => ({ type: "listItem", content: [paragraph([s])] });
|
|
12
|
+
const HEADING = /^(#{1,6}) (.*)$/;
|
|
13
|
+
const BULLET = /^- /;
|
|
14
|
+
const ORDERED = /^\d+\. /;
|
|
15
|
+
const QUOTE = /^> ?/;
|
|
9
16
|
export function plainToRichtext(text) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
18
|
+
const content = [];
|
|
19
|
+
let para = [];
|
|
20
|
+
const flush = () => {
|
|
21
|
+
if (para.length > 0)
|
|
22
|
+
content.push(paragraph(para));
|
|
23
|
+
para = [];
|
|
16
24
|
};
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < lines.length) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
if (line.trim() === "") {
|
|
29
|
+
flush();
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const heading = HEADING.exec(line);
|
|
34
|
+
if (heading) {
|
|
35
|
+
flush();
|
|
36
|
+
content.push({ type: "heading", attrs: { level: heading[1].length }, content: textOf(heading[2]) });
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (BULLET.test(line) || ORDERED.test(line)) {
|
|
41
|
+
flush();
|
|
42
|
+
const ordered = ORDERED.test(line);
|
|
43
|
+
const re = ordered ? ORDERED : BULLET;
|
|
44
|
+
const items = [];
|
|
45
|
+
while (i < lines.length && re.test(lines[i])) {
|
|
46
|
+
items.push(listItem(lines[i].replace(re, "")));
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
content.push({ type: ordered ? "orderedList" : "bulletList", content: items });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (QUOTE.test(line)) {
|
|
53
|
+
flush();
|
|
54
|
+
const paras = [];
|
|
55
|
+
while (i < lines.length && QUOTE.test(lines[i])) {
|
|
56
|
+
paras.push(paragraph([lines[i].replace(QUOTE, "")]));
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
content.push({ type: "blockquote", content: paras });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
para.push(line);
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
flush();
|
|
66
|
+
return { type: "doc", content };
|
|
67
|
+
}
|
|
68
|
+
/** Inline text of a node: text runs joined, a hard break as a newline, marks ignored. */
|
|
69
|
+
function inline(node) {
|
|
70
|
+
if (node.type === "text")
|
|
71
|
+
return node.text ?? "";
|
|
72
|
+
if (node.type === "hardBreak")
|
|
73
|
+
return "\n";
|
|
74
|
+
return (node.content ?? []).map(inline).join("");
|
|
75
|
+
}
|
|
76
|
+
/** Inline text of a node, with hard breaks flattened to spaces so the line syntax stays intact. A hard break cannot survive inside a prefixed line (list item, blockquote, or heading), so it flattens to a space (DESIGN.md §3 Richtext records the loss). */
|
|
77
|
+
function oneLine(node) {
|
|
78
|
+
return inline(node).replace(/\n/g, " ");
|
|
79
|
+
}
|
|
80
|
+
const itemText = (li) => (li.content ?? []).map(oneLine).join(" ");
|
|
81
|
+
function block(node) {
|
|
82
|
+
switch (node.type) {
|
|
83
|
+
case "heading": {
|
|
84
|
+
const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
|
|
85
|
+
return `${"#".repeat(level)} ${oneLine(node)}`;
|
|
86
|
+
}
|
|
87
|
+
case "bulletList": return (node.content ?? []).map((li) => `- ${itemText(li)}`).join("\n");
|
|
88
|
+
case "orderedList": return (node.content ?? []).map((li, i) => `${i + 1}. ${itemText(li)}`).join("\n");
|
|
89
|
+
case "blockquote": return (node.content ?? []).map((p) => `> ${oneLine(p)}`).join("\n");
|
|
90
|
+
default: return inline(node);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function richtextToPlain(doc) {
|
|
94
|
+
const d = doc;
|
|
95
|
+
if (!d || typeof d !== "object" || d.type !== "doc" || !Array.isArray(d.content))
|
|
96
|
+
return "";
|
|
97
|
+
return d.content.map(block).filter((b) => b !== "").join("\n\n");
|
|
17
98
|
}
|
|
@@ -3,6 +3,10 @@ import type { ZonePolicy } from "../zones.ts";
|
|
|
3
3
|
export type AdminField = Record<string, unknown> & {
|
|
4
4
|
type: string;
|
|
5
5
|
target?: string;
|
|
6
|
+
/** An object's subfields, serialized. */
|
|
7
|
+
fields?: Record<string, AdminField>;
|
|
8
|
+
/** A list's item descriptor, serialized. */
|
|
9
|
+
item?: AdminField;
|
|
6
10
|
};
|
|
7
11
|
export type AdminSection = {
|
|
8
12
|
name: string;
|
|
@@ -33,10 +37,21 @@ export type AdminPageTemplate = {
|
|
|
33
37
|
slugs?: Record<string, string>;
|
|
34
38
|
zones: Record<string, ZonePolicy>;
|
|
35
39
|
};
|
|
40
|
+
export type AdminShared = {
|
|
41
|
+
name: string;
|
|
42
|
+
title: string;
|
|
43
|
+
/** The section a visual item renders through; absent = an object item. */
|
|
44
|
+
section?: string;
|
|
45
|
+
versions: boolean;
|
|
46
|
+
fields: Record<string, AdminField>;
|
|
47
|
+
/** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
|
|
48
|
+
styles: Record<string, AdminField>;
|
|
49
|
+
};
|
|
36
50
|
export type AdminRegistry = {
|
|
37
51
|
sections: AdminSection[];
|
|
38
52
|
collections: AdminCollection[];
|
|
39
53
|
pages: AdminPageTemplate[];
|
|
54
|
+
shared: AdminShared[];
|
|
40
55
|
locales: {
|
|
41
56
|
default: string;
|
|
42
57
|
supported: string[];
|
package/dist/admin/serialize.js
CHANGED
|
@@ -10,6 +10,9 @@ function serializeDescriptor(d) {
|
|
|
10
10
|
else if (key === "fields" && d.type === "object") {
|
|
11
11
|
out.fields = serializeMap(value);
|
|
12
12
|
}
|
|
13
|
+
else if (key === "item" && d.type === "list") {
|
|
14
|
+
out.item = serializeDescriptor(value);
|
|
15
|
+
}
|
|
13
16
|
else if (typeof value !== "function") {
|
|
14
17
|
out[key] = value;
|
|
15
18
|
}
|
|
@@ -53,6 +56,14 @@ export function serializeAdminConfig(config) {
|
|
|
53
56
|
zones: p.zones ?? {},
|
|
54
57
|
};
|
|
55
58
|
}),
|
|
59
|
+
shared: (config.registry.shared ?? []).map((s) => ({
|
|
60
|
+
name: s.name,
|
|
61
|
+
title: s.title,
|
|
62
|
+
...(s.section !== undefined ? { section: s.section } : {}),
|
|
63
|
+
versions: s.versions === true,
|
|
64
|
+
fields: serializeMap(s.fields),
|
|
65
|
+
styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
|
|
66
|
+
})),
|
|
56
67
|
locales: config.locales,
|
|
57
68
|
homePageSlug: config.homePageSlug,
|
|
58
69
|
pageDepth: config.pageDepth,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { Descriptor } from "../fields.ts";
|
|
3
|
+
import type { EntryRecord, EntryStore } from "../entry-store.ts";
|
|
4
|
+
import { type SharedSchema } from "../shared.ts";
|
|
5
|
+
/** The merged fields a fresh item starts from: sampled field values, and
|
|
6
|
+
* for a visual item `$styles` = the resolved style defaults with the
|
|
7
|
+
* sample's style values on top. Unsampled fields stay absent. */
|
|
8
|
+
export declare function initialSharedFields(item: SharedSchema, resolvedStyles: Record<string, Descriptor>): Record<string, unknown>;
|
|
9
|
+
export declare function ensureSharedItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { STYLES_KEY } from "../shared.js";
|
|
2
|
+
/** The merged fields a fresh item starts from: sampled field values, and
|
|
3
|
+
* for a visual item `$styles` = the resolved style defaults with the
|
|
4
|
+
* sample's style values on top. Unsampled fields stay absent. */
|
|
5
|
+
export function initialSharedFields(item, resolvedStyles) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const key of Object.keys(item.fields)) {
|
|
8
|
+
const sampled = item.sample?.[key];
|
|
9
|
+
if (sampled !== undefined)
|
|
10
|
+
out[key] = sampled;
|
|
11
|
+
}
|
|
12
|
+
if (item.section !== undefined) {
|
|
13
|
+
const styles = {};
|
|
14
|
+
for (const [key, d] of Object.entries(resolvedStyles)) {
|
|
15
|
+
const value = item.sample?.[key] ?? d.default;
|
|
16
|
+
if (value !== undefined)
|
|
17
|
+
styles[key] = value;
|
|
18
|
+
}
|
|
19
|
+
out[STYLES_KEY] = styles;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
export async function ensureSharedItems(entries, config) {
|
|
24
|
+
const { default: def, supported } = config.locales;
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const item of config.registry.shared ?? []) {
|
|
27
|
+
let [record] = await entries.list(item.name);
|
|
28
|
+
if (!record) {
|
|
29
|
+
try {
|
|
30
|
+
const styles = item.section !== undefined ? config.resolvedStyles(item.section) : {};
|
|
31
|
+
record = await entries.create(item.name, initialSharedFields(item, styles), { locale: def });
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
35
|
+
// Another admin load won the create: read theirs. Anything else is
|
|
36
|
+
// worth saying out loud, and must not take the other items down.
|
|
37
|
+
if (message.includes("it is a singleton"))
|
|
38
|
+
[record] = await entries.list(item.name);
|
|
39
|
+
else
|
|
40
|
+
console.warn(`smoodly: could not materialize shared item "${item.name}": ${message}`);
|
|
41
|
+
if (!record)
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const locale of supported) {
|
|
46
|
+
if (record.locales[locale])
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
record = await entries.addLocale(item.name, record.id, locale, { from: def });
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
53
|
+
if (!message.includes("already exists in locale")) {
|
|
54
|
+
console.warn(`smoodly: could not materialize shared item "${item.name}" in "${locale}": ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
out[item.name] = record;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -14,6 +14,8 @@ import { makeClientOps } from "../client-ops.js";
|
|
|
14
14
|
import { PagesList } from "./PagesList.js";
|
|
15
15
|
import { EntriesList } from "./EntriesList.js";
|
|
16
16
|
import { EntryForm } from "./EntryForm.js";
|
|
17
|
+
import { SharedList } from "./SharedList.js";
|
|
18
|
+
import { SharedForm } from "./SharedForm.js";
|
|
17
19
|
import { EditorView } from "../editor/EditorView.js";
|
|
18
20
|
import { LoginView } from "./LoginView.js";
|
|
19
21
|
import { useAdminSession } from "./session.js";
|
|
@@ -22,12 +24,12 @@ import { BASE } from "./base.js";
|
|
|
22
24
|
import { ErrorPane } from "./ErrorPane.js";
|
|
23
25
|
/** Nav items the registry can't produce yet: shown, disabled, so the shape
|
|
24
26
|
* of the product is visible — the same set the design system's SideNav has. */
|
|
25
|
-
const PLANNED = ["
|
|
27
|
+
const PLANNED = ["Media", "Settings"];
|
|
26
28
|
function Nav({ registry, active, email, onSignOut }) {
|
|
27
29
|
const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
|
|
28
30
|
const name = email?.split("@")[0] ?? "Editor";
|
|
29
31
|
const initials = name.slice(0, 2).toUpperCase();
|
|
30
|
-
return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
|
|
32
|
+
return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), registry.shared.length > 0 && item(`${BASE}/shared`, "Shared content", "shared"), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
|
|
31
33
|
}
|
|
32
34
|
export function AdminApp({ registry, segments, op, auth }) {
|
|
33
35
|
const session = useAdminSession(auth);
|
|
@@ -49,6 +51,10 @@ export function AdminApp({ registry, segments, op, auth }) {
|
|
|
49
51
|
main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
|
|
50
52
|
else if (isCollection && tail)
|
|
51
53
|
main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
|
|
54
|
+
else if (head === "shared" && !tail)
|
|
55
|
+
main = _jsx(SharedList, { ops: ops, registry: registry });
|
|
56
|
+
else if (head === "shared" && tail)
|
|
57
|
+
main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
52
58
|
else
|
|
53
59
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
54
60
|
return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));
|
|
@@ -223,7 +223,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
223
223
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
224
224
|
const shared = Object.entries(meta.fields).filter(([key]) => !localeKeys.includes(key));
|
|
225
225
|
const own = Object.entries(meta.fields).filter(([key]) => localeKeys.includes(key));
|
|
226
|
-
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key],
|
|
226
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
|
|
227
227
|
const absent = !isNew && record !== null && fields === null && !record.locales[locale];
|
|
228
228
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [{ label: meta.title, href: `${BASE}/${collection}` }, { label: heading }] }), multilingual && !isNew && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })), multilingual && isNew && _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => switchLocale(entrySourceLocale(record, registry)), children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
|
|
229
229
|
display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px",
|