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.
Files changed (64) hide show
  1. package/README.md +224 -4
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +64 -8
  4. package/dist/admin/forms/FieldWidget.d.ts +7 -3
  5. package/dist/admin/forms/FieldWidget.js +85 -7
  6. package/dist/admin/forms/list.d.ts +18 -0
  7. package/dist/admin/forms/list.js +81 -0
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +84 -1
  11. package/dist/admin/ops.d.ts +22 -1
  12. package/dist/admin/richtext.d.ts +13 -11
  13. package/dist/admin/richtext.js +94 -13
  14. package/dist/admin/serialize.d.ts +15 -0
  15. package/dist/admin/serialize.js +11 -0
  16. package/dist/admin/shared-items.d.ts +9 -0
  17. package/dist/admin/shared-items.js +61 -0
  18. package/dist/admin/shell/AdminApp.js +8 -2
  19. package/dist/admin/shell/EntryForm.js +1 -1
  20. package/dist/admin/shell/SharedForm.d.ts +7 -0
  21. package/dist/admin/shell/SharedForm.js +146 -0
  22. package/dist/admin/shell/SharedList.d.ts +6 -0
  23. package/dist/admin/shell/SharedList.js +62 -0
  24. package/dist/admin/shell/shared-list.d.ts +14 -0
  25. package/dist/admin/shell/shared-list.js +17 -0
  26. package/dist/admin/tree-ops.d.ts +12 -5
  27. package/dist/admin/tree-ops.js +39 -8
  28. package/dist/admin/ui/theme.d.ts +1 -1
  29. package/dist/admin/ui/theme.js +13 -0
  30. package/dist/admin/validate.d.ts +7 -2
  31. package/dist/admin/validate.js +43 -13
  32. package/dist/collections.d.ts +4 -1
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.js +35 -1
  35. package/dist/entry-store.d.ts +9 -1
  36. package/dist/entry-store.js +32 -4
  37. package/dist/fields.d.ts +16 -1
  38. package/dist/fields.js +49 -4
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +7 -1
  41. package/dist/next/page-renderer.d.ts +4 -0
  42. package/dist/next/page-renderer.js +14 -1
  43. package/dist/page.js +5 -1
  44. package/dist/refs.js +13 -0
  45. package/dist/resolve.d.ts +3 -0
  46. package/dist/resolve.js +41 -0
  47. package/dist/revalidate.d.ts +4 -0
  48. package/dist/revalidate.js +4 -0
  49. package/dist/richtext-render.d.ts +3 -0
  50. package/dist/richtext-render.js +62 -0
  51. package/dist/shared-id.d.ts +3 -0
  52. package/dist/shared-id.js +36 -0
  53. package/dist/shared.d.ts +46 -0
  54. package/dist/shared.js +36 -0
  55. package/dist/site.d.ts +10 -0
  56. package/dist/site.js +28 -1
  57. package/dist/sql-space.d.ts +5 -1
  58. package/dist/sql-space.js +9 -10
  59. package/dist/sql.js +1 -19
  60. package/dist/supabase-entry-store.d.ts +9 -1
  61. package/dist/supabase-entry-store.js +60 -7
  62. package/dist/zones.d.ts +6 -2
  63. package/dist/zones.js +8 -2
  64. 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
+ }
@@ -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";
@@ -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";
@@ -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))),
@@ -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>>;
@@ -1,14 +1,16 @@
1
- type TextNode = {
2
- type: "text";
3
- text: string;
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 Paragraph = {
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: Paragraph[];
13
+ content: RichtextNode[];
13
14
  };
14
- export {};
15
+ export declare function plainToRichtext(text: string): RichtextDoc;
16
+ export declare function richtextToPlain(doc: unknown): string;
@@ -1,17 +1,98 @@
1
- export function richtextToPlain(doc) {
2
- const d = doc;
3
- if (!d || d.type !== "doc" || !Array.isArray(d.content))
4
- return "";
5
- return d.content
6
- .map((p) => (p.content ?? []).map((t) => t.text ?? "").join(""))
7
- .join("\n");
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
- return {
11
- type: "doc",
12
- content: text.split("\n").map((line) => ({
13
- type: "paragraph",
14
- ...(line ? { content: [{ type: "text", text: line }] } : {}),
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[];
@@ -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 = ["Shared Content", "Media", "Settings"];
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], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, 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",
@@ -0,0 +1,7 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ export declare function SharedForm({ ops, registry, name }: {
4
+ ops: AdminOps;
5
+ registry: AdminRegistry;
6
+ name: string;
7
+ }): import("react").JSX.Element;