smoodly 0.0.9 → 0.0.11

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 (125) hide show
  1. package/README.md +167 -18
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  4. package/dist/admin/editor/CanvasOverlay.js +39 -0
  5. package/dist/admin/editor/EditorView.js +56 -237
  6. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  7. package/dist/admin/editor/InsertPicker.js +18 -0
  8. package/dist/admin/editor/NoticePane.d.ts +4 -0
  9. package/dist/admin/editor/NoticePane.js +4 -0
  10. package/dist/admin/editor/Outline.d.ts +18 -0
  11. package/dist/admin/editor/Outline.js +12 -0
  12. package/dist/admin/editor/locale-url.d.ts +12 -0
  13. package/dist/admin/editor/locale-url.js +32 -0
  14. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  15. package/dist/admin/editor/overlay-geometry.js +27 -0
  16. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  17. package/dist/admin/editor/useCanvasBridge.js +53 -0
  18. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  19. package/dist/admin/editor/usePageLocale.js +88 -0
  20. package/dist/admin/forms/CropEditor.d.ts +10 -0
  21. package/dist/admin/forms/CropEditor.js +61 -0
  22. package/dist/admin/forms/FieldWidget.js +8 -26
  23. package/dist/admin/forms/MediaGrid.d.ts +11 -0
  24. package/dist/admin/forms/MediaGrid.js +16 -0
  25. package/dist/admin/forms/MediaPicker.d.ts +8 -0
  26. package/dist/admin/forms/MediaPicker.js +44 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +9 -0
  28. package/dist/admin/forms/MediaWidget.js +83 -0
  29. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  30. package/dist/admin/forms/RichtextToolbar.js +37 -0
  31. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  32. package/dist/admin/forms/RichtextWidget.js +123 -0
  33. package/dist/admin/forms/assets-context.d.ts +19 -0
  34. package/dist/admin/forms/assets-context.js +35 -0
  35. package/dist/admin/forms/crop-editor.d.ts +13 -0
  36. package/dist/admin/forms/crop-editor.js +72 -0
  37. package/dist/admin/forms/measure.d.ts +2 -0
  38. package/dist/admin/forms/measure.js +39 -0
  39. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  40. package/dist/admin/forms/richtext-extensions.js +74 -0
  41. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  42. package/dist/admin/forms/richtext-paste.js +59 -0
  43. package/dist/admin/forms/upload.d.ts +17 -0
  44. package/dist/admin/forms/upload.js +33 -0
  45. package/dist/admin/index.d.ts +1 -2
  46. package/dist/admin/index.js +0 -1
  47. package/dist/admin/next/create-admin.d.ts +3 -7
  48. package/dist/admin/next/create-admin.js +14 -5
  49. package/dist/admin/next/op-handler.d.ts +3 -0
  50. package/dist/admin/next/op-handler.js +1 -0
  51. package/dist/admin/ops-impl.d.ts +3 -0
  52. package/dist/admin/ops-impl.js +83 -2
  53. package/dist/admin/ops.d.ts +27 -0
  54. package/dist/admin/richtext-doc.d.ts +7 -0
  55. package/dist/admin/richtext-doc.js +18 -0
  56. package/dist/admin/serialize.d.ts +5 -0
  57. package/dist/admin/serialize.js +1 -0
  58. package/dist/admin/shell/AdminApp.d.ts +1 -2
  59. package/dist/admin/shell/AdminApp.js +18 -19
  60. package/dist/admin/shell/EntriesList.js +3 -2
  61. package/dist/admin/shell/EntryForm.js +8 -24
  62. package/dist/admin/shell/IconRail.d.ts +7 -0
  63. package/dist/admin/shell/IconRail.js +73 -0
  64. package/dist/admin/shell/ListView.js +3 -1
  65. package/dist/admin/shell/MediaScreen.d.ts +8 -0
  66. package/dist/admin/shell/MediaScreen.js +86 -0
  67. package/dist/admin/shell/PagesList.js +3 -2
  68. package/dist/admin/shell/RouteLink.d.ts +4 -0
  69. package/dist/admin/shell/RouteLink.js +12 -0
  70. package/dist/admin/shell/SharedForm.js +3 -21
  71. package/dist/admin/shell/media-list.d.ts +8 -0
  72. package/dist/admin/shell/media-list.js +14 -0
  73. package/dist/admin/shell/router.d.ts +37 -0
  74. package/dist/admin/shell/router.js +84 -0
  75. package/dist/admin/shell/session.js +3 -1
  76. package/dist/admin/ui/Modal.d.ts +8 -0
  77. package/dist/admin/ui/Modal.js +15 -0
  78. package/dist/admin/ui/primitives.d.ts +3 -2
  79. package/dist/admin/ui/primitives.js +8 -6
  80. package/dist/admin/ui/theme.d.ts +1 -1
  81. package/dist/admin/ui/theme.js +133 -24
  82. package/dist/admin/validate.js +16 -0
  83. package/dist/asset-store.d.ts +86 -0
  84. package/dist/asset-store.js +141 -0
  85. package/dist/collections.d.ts +0 -14
  86. package/dist/collections.js +2 -10
  87. package/dist/config.d.ts +12 -0
  88. package/dist/config.js +17 -5
  89. package/dist/fields.d.ts +10 -11
  90. package/dist/fields.js +16 -2
  91. package/dist/image/SmoodlyImage.d.ts +18 -0
  92. package/dist/image/SmoodlyImage.js +16 -0
  93. package/dist/image/SmoodlyVideo.d.ts +7 -0
  94. package/dist/image/SmoodlyVideo.js +5 -0
  95. package/dist/image/crop-math.d.ts +14 -0
  96. package/dist/image/crop-math.js +38 -0
  97. package/dist/image/index.d.ts +6 -0
  98. package/dist/image/index.js +5 -0
  99. package/dist/image/loaders.d.ts +15 -0
  100. package/dist/image/loaders.js +5 -0
  101. package/dist/image/next-config.d.ts +15 -0
  102. package/dist/image/next-config.js +27 -0
  103. package/dist/image-dimensions.d.ts +4 -0
  104. package/dist/image-dimensions.js +25 -0
  105. package/dist/index.d.ts +12 -4
  106. package/dist/index.js +8 -2
  107. package/dist/media.d.ts +108 -0
  108. package/dist/media.js +129 -0
  109. package/dist/refs.d.ts +2 -0
  110. package/dist/refs.js +25 -4
  111. package/dist/resolve.d.ts +19 -4
  112. package/dist/resolve.js +55 -2
  113. package/dist/site.d.ts +2 -0
  114. package/dist/site.js +21 -3
  115. package/dist/sql-space.d.ts +8 -1
  116. package/dist/sql-space.js +28 -0
  117. package/dist/sql.js +30 -0
  118. package/dist/store.js +3 -0
  119. package/dist/supabase-asset-store.d.ts +21 -0
  120. package/dist/supabase-asset-store.js +170 -0
  121. package/dist/toolset.d.ts +30 -0
  122. package/dist/toolset.js +49 -0
  123. package/package.json +15 -1
  124. package/dist/admin/richtext.d.ts +0 -16
  125. package/dist/admin/richtext.js +0 -98
@@ -0,0 +1,123 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The richtext field widget (spec 2026-09-08 §3): a TipTap editor under a
4
+ // fixed toolbar. The editor owns its document and emits TipTap JSON on
5
+ // every change; the parent stores the emitted document by reference, so
6
+ // a value that is not the last emitted document is a change from outside
7
+ // (load, locale switch, a list row moving) and re-seeds the editor. The
8
+ // schema is always full; `allowed` gates creation (extensions, paste,
9
+ // toolbar). Only this file and richtext-extensions.ts import TipTap.
10
+ import { useEffect, useMemo, useRef, useState } from "react";
11
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
12
+ import { Slice } from "@tiptap/pm/model";
13
+ import { allowedOf } from "../../toolset.js";
14
+ import { plainDoc } from "../richtext-doc.js";
15
+ import { Button } from "../ui/primitives.js";
16
+ import { richtextExtensions } from "./richtext-extensions.js";
17
+ import { gatePastedSlice } from "./richtext-paste.js";
18
+ import { INACTIVE, RichtextToolbar } from "./RichtextToolbar.js";
19
+ const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
20
+ const LEVELS = [1, 2, 3, 4, 5, 6];
21
+ const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
22
+ export function RichtextWidget({ value, toolset, onChange }) {
23
+ const allowed = useMemo(() => allowedOf(toolset), [toolset]);
24
+ // The parent's onChange closes over its render; the editor's onUpdate is
25
+ // created once, so read the latest through a ref.
26
+ const onChangeRef = useRef(onChange);
27
+ onChangeRef.current = onChange;
28
+ // The last document this widget emitted — anything else arriving as
29
+ // `value` came from outside and re-seeds the editor.
30
+ const emitted = useRef(value);
31
+ const [link, setLink] = useState({ open: false, href: "" });
32
+ const openLinkRef = useRef(() => { });
33
+ const editor = useEditor({
34
+ immediatelyRender: false,
35
+ extensions: richtextExtensions(allowed, { onLink: () => openLinkRef.current() }),
36
+ content: isDoc(value) ? value : EMPTY,
37
+ editorProps: {
38
+ attributes: { class: "sm-richtext__content" },
39
+ transformPasted: (slice, view) => Slice.fromJSON(view.state.schema, gatePastedSlice(slice.toJSON(), allowed)),
40
+ },
41
+ onUpdate: ({ editor }) => {
42
+ const doc = plainDoc(editor.getJSON());
43
+ emitted.current = doc;
44
+ onChangeRef.current(doc);
45
+ },
46
+ }, [allowed]);
47
+ useEffect(() => {
48
+ if (!editor || value === emitted.current)
49
+ return;
50
+ emitted.current = value;
51
+ editor.commands.setContent(isDoc(value) ? value : EMPTY, { emitUpdate: false });
52
+ }, [editor, value]);
53
+ const active = useEditorState({
54
+ editor,
55
+ selector: ({ editor }) => editor
56
+ ? {
57
+ bold: editor.isActive("bold"),
58
+ italic: editor.isActive("italic"),
59
+ link: editor.isActive("link"),
60
+ bulletList: editor.isActive("bulletList"),
61
+ orderedList: editor.isActive("orderedList"),
62
+ blockquote: editor.isActive("blockquote"),
63
+ heading: LEVELS.find((level) => editor.isActive("heading", { level })) ?? null,
64
+ }
65
+ : INACTIVE,
66
+ });
67
+ // Read the caret's link from the editor at open time, not from the last
68
+ // render: a selection change and the press can land in the same tick.
69
+ const openLink = () => setLink({ open: true, href: editor?.getAttributes("link").href ?? "" });
70
+ openLinkRef.current = openLink;
71
+ const closeLink = () => {
72
+ setLink({ open: false, href: "" });
73
+ editor?.commands.focus();
74
+ };
75
+ const applyLink = () => {
76
+ if (!editor)
77
+ return;
78
+ const href = link.href.trim();
79
+ const chain = editor.chain().focus().extendMarkRange("link");
80
+ if (href === "")
81
+ chain.unsetLink().run();
82
+ else
83
+ chain.setLink({ href }).run();
84
+ setLink({ open: false, href: "" });
85
+ };
86
+ const removeLink = () => {
87
+ editor?.chain().focus().extendMarkRange("link").unsetLink().run();
88
+ setLink({ open: false, href: "" });
89
+ };
90
+ const run = (command) => {
91
+ if (!editor)
92
+ return;
93
+ if (command === "link") {
94
+ openLink();
95
+ return;
96
+ }
97
+ const chain = editor.chain().focus();
98
+ if (typeof command === "object")
99
+ chain.toggleHeading({ level: command.heading }).run();
100
+ else if (command === "bold")
101
+ chain.toggleBold().run();
102
+ else if (command === "italic")
103
+ chain.toggleItalic().run();
104
+ else if (command === "bulletList")
105
+ chain.toggleBulletList().run();
106
+ else if (command === "orderedList")
107
+ chain.toggleOrderedList().run();
108
+ else if (command === "blockquote")
109
+ chain.toggleBlockquote().run();
110
+ };
111
+ return (_jsxs("div", { className: "sm-richtext", children: [_jsx(RichtextToolbar, { allowed: allowed, active: active ?? INACTIVE, onCommand: run }), link.open && (_jsxs("div", { className: "sm-richtext__link", children: [_jsx("input", { className: "sm-input", placeholder: "https://\u2026 or /path", value: link.href, autoFocus: true, "aria-label": "Link URL", onChange: (e) => setLink({ open: true, href: e.target.value }), onKeyDown: (e) => {
112
+ // Escape must not reach the editor's window handler, which clears the block selection.
113
+ if (e.key === "Enter") {
114
+ e.preventDefault();
115
+ applyLink();
116
+ }
117
+ if (e.key === "Escape") {
118
+ e.preventDefault();
119
+ e.stopPropagation();
120
+ closeLink();
121
+ }
122
+ } }), _jsx(Button, { onClick: applyLink, children: "Apply" }), _jsx(Button, { variant: "secondary", onClick: removeLink, children: "Remove" })] })), _jsx(EditorContent, { editor: editor })] }));
123
+ }
@@ -0,0 +1,19 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ import type { AssetRecord } from "../../media.ts";
5
+ import { type UploadDeps } from "./upload.ts";
6
+ export type AssetsApi = {
7
+ ops: AdminOps["assets"];
8
+ get(id: string): Promise<AssetRecord | null>;
9
+ remember(record: AssetRecord): void;
10
+ upload: UploadDeps;
11
+ media: AdminRegistry["media"];
12
+ };
13
+ export declare function AssetsProvider({ ops, media, upload, children }: {
14
+ ops: AdminOps;
15
+ media: AdminRegistry["media"];
16
+ upload?: UploadDeps;
17
+ children: ReactNode;
18
+ }): ReactElement;
19
+ export declare function useAssets(): AssetsApi;
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The one place widgets reach the assets ops: a context with a small
4
+ // record cache (an id fetched once per shell lifetime), the field
5
+ // limits from the registry, and the injectable upload deps.
6
+ import { createContext, useContext, useMemo, useRef } from "react";
7
+ import { putSigned } from "./upload.js";
8
+ import { measureFile } from "./measure.js";
9
+ const Ctx = createContext(null);
10
+ export function AssetsProvider({ ops, media, upload, children }) {
11
+ const cache = useRef(new Map());
12
+ const api = useMemo(() => ({
13
+ ops: ops.assets,
14
+ media,
15
+ upload: upload ?? { put: putSigned, measure: measureFile },
16
+ remember: (record) => { cache.current.set(record.id, record); },
17
+ get: async (id) => {
18
+ const hit = cache.current.get(id);
19
+ if (hit)
20
+ return hit;
21
+ const r = await ops.assets.get(id);
22
+ if (!r.ok)
23
+ return null;
24
+ cache.current.set(id, r.data);
25
+ return r.data;
26
+ },
27
+ }), [ops, media, upload]);
28
+ return _jsx(Ctx.Provider, { value: api, children: children });
29
+ }
30
+ export function useAssets() {
31
+ const api = useContext(Ctx);
32
+ if (!api)
33
+ throw new Error("smoodly: useAssets() needs an <AssetsProvider> above it.");
34
+ return api;
35
+ }
@@ -0,0 +1,13 @@
1
+ import { type Crop, type Hotspot, type ImageValue } from "../../media.ts";
2
+ export type Handle = "nw" | "ne" | "sw" | "se";
3
+ export type CropState = {
4
+ crop: Crop;
5
+ hotspot: Hotspot;
6
+ };
7
+ export declare const MIN_SIDE = 0.05;
8
+ export declare function fitAspect(aspect: number, imageAspect: number): Crop;
9
+ export declare function moveCrop(crop: Crop, dx: number, dy: number): Crop;
10
+ export declare function resizeCrop(crop: Crop, handle: Handle, dx: number, dy: number, lock?: number, min?: number): Crop;
11
+ export declare function placeHotspot(h: Hotspot, crop: Crop): Hotspot;
12
+ export declare function relock(state: CropState, aspect: number | undefined, imageAspect: number): CropState;
13
+ export declare function initialState(value: ImageValue | undefined, aspect: number | undefined, imageAspect: number): CropState;
@@ -0,0 +1,72 @@
1
+ // The crop editor's math, pure (spec 2026-09-08 media §10): everything in
2
+ // fractions of the original image. A "lock" is the wanted pixel ratio
3
+ // divided by the image's own, i.e. the ratio crop.width / crop.height
4
+ // must keep.
5
+ import { CENTER_HOTSPOT, FULL_CROP, clamp01 } from "../../media.js";
6
+ export const MIN_SIDE = 0.05;
7
+ export function fitAspect(aspect, imageAspect) {
8
+ const fa = aspect / imageAspect;
9
+ const width = fa <= 1 ? fa : 1;
10
+ const height = fa <= 1 ? 1 : 1 / fa;
11
+ return { left: (1 - width) / 2, top: (1 - height) / 2, width, height };
12
+ }
13
+ export function moveCrop(crop, dx, dy) {
14
+ return {
15
+ ...crop,
16
+ left: Math.min(Math.max(crop.left + dx, 0), 1 - crop.width),
17
+ top: Math.min(Math.max(crop.top + dy, 0), 1 - crop.height),
18
+ };
19
+ }
20
+ export function resizeCrop(crop, handle, dx, dy, lock, min = MIN_SIDE) {
21
+ const right = crop.left + crop.width, bottom = crop.top + crop.height;
22
+ const ax = handle.includes("w") ? right : crop.left; // anchor: the opposite corner
23
+ const ay = handle.includes("n") ? bottom : crop.top;
24
+ const mx = clamp01((handle.includes("w") ? crop.left : right) + dx);
25
+ const my = clamp01((handle.includes("n") ? crop.top : bottom) + dy);
26
+ // Signed: a corner dragged past its anchor collapses to `min`, never flips.
27
+ let width = Math.max(handle.includes("w") ? ax - mx : mx - ax, min);
28
+ let height = Math.max(handle.includes("n") ? ay - my : my - ay, min);
29
+ const roomX = handle.includes("w") ? ax : 1 - ax; // how far the moving corner may go
30
+ const roomY = handle.includes("n") ? ay : 1 - ay;
31
+ if (lock !== undefined) {
32
+ height = width / lock;
33
+ if (height > roomY) {
34
+ height = roomY;
35
+ width = height * lock;
36
+ }
37
+ if (width > roomX) {
38
+ width = roomX;
39
+ height = width / lock;
40
+ }
41
+ if (height < min) {
42
+ height = min;
43
+ width = height * lock;
44
+ }
45
+ }
46
+ else {
47
+ width = Math.min(width, roomX);
48
+ height = Math.min(height, roomY);
49
+ }
50
+ return {
51
+ left: handle.includes("w") ? ax - width : ax,
52
+ top: handle.includes("n") ? ay - height : ay,
53
+ width, height,
54
+ };
55
+ }
56
+ export function placeHotspot(h, crop) {
57
+ return {
58
+ x: Math.min(Math.max(h.x, crop.left), crop.left + crop.width),
59
+ y: Math.min(Math.max(h.y, crop.top), crop.top + crop.height),
60
+ };
61
+ }
62
+ const atRatio = (crop, lock) => Math.abs(crop.width / crop.height - lock) < 1e-3;
63
+ export function relock(state, aspect, imageAspect) {
64
+ if (aspect === undefined)
65
+ return { crop: state.crop, hotspot: placeHotspot(state.hotspot, state.crop) };
66
+ const lock = aspect / imageAspect;
67
+ const crop = atRatio(state.crop, lock) ? state.crop : fitAspect(aspect, imageAspect);
68
+ return { crop, hotspot: placeHotspot(state.hotspot, crop) };
69
+ }
70
+ export function initialState(value, aspect, imageAspect) {
71
+ return relock({ crop: value?.crop ?? FULL_CROP, hotspot: value?.hotspot ?? CENTER_HOTSPOT }, aspect, imageAspect);
72
+ }
@@ -0,0 +1,2 @@
1
+ import type { Measured } from "./upload.ts";
2
+ export declare function measureFile(file: Blob, mime: string): Promise<Measured>;
@@ -0,0 +1,39 @@
1
+ const objectUrl = (file) => URL.createObjectURL(file);
2
+ function measureImage(file) {
3
+ return new Promise((resolve) => {
4
+ const url = objectUrl(file);
5
+ const img = new Image();
6
+ img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }); URL.revokeObjectURL(url); };
7
+ img.onerror = () => { resolve({}); URL.revokeObjectURL(url); };
8
+ img.src = url;
9
+ });
10
+ }
11
+ function measureVideo(file) {
12
+ return new Promise((resolve) => {
13
+ const url = objectUrl(file);
14
+ const video = document.createElement("video");
15
+ video.preload = "metadata";
16
+ video.muted = true;
17
+ const done = (m) => { resolve(m); URL.revokeObjectURL(url); };
18
+ video.onerror = () => done({});
19
+ video.onloadedmetadata = () => {
20
+ const base = { width: video.videoWidth, height: video.videoHeight, duration: video.duration };
21
+ video.onseeked = () => {
22
+ const canvas = document.createElement("canvas");
23
+ canvas.width = video.videoWidth;
24
+ canvas.height = video.videoHeight;
25
+ canvas.getContext("2d")?.drawImage(video, 0, 0);
26
+ canvas.toBlob((blob) => done(blob ? { ...base, poster: blob } : base), "image/jpeg", 0.85);
27
+ };
28
+ video.currentTime = Math.min(1, video.duration / 2);
29
+ };
30
+ video.src = url;
31
+ });
32
+ }
33
+ export function measureFile(file, mime) {
34
+ if (mime.startsWith("image/"))
35
+ return measureImage(file);
36
+ if (mime.startsWith("video/"))
37
+ return measureVideo(file);
38
+ return Promise.resolve({});
39
+ }
@@ -0,0 +1,5 @@
1
+ import { type AnyExtension } from "@tiptap/core";
2
+ import type { Allowed } from "../../toolset.ts";
3
+ export declare function richtextExtensions(allowed: Allowed, hooks: {
4
+ onLink(): void;
5
+ }): AnyExtension[];
@@ -0,0 +1,74 @@
1
+ // The editor's extension set (spec 2026-09-08 §2): always the FULL schema
2
+ // of what `RichText` draws, so content a toolset forbids survives the
3
+ // round trip; the toolset gates only creation — input rules, paste rules
4
+ // and keyboard shortcuts here, the toolbar in RichtextToolbar, paste in
5
+ // richtext-paste. StarterKit supplies document, paragraph, text, hard
6
+ // break, list item, list keymap and undo/redo; every gated node or mark
7
+ // is switched off there and added back extended.
8
+ import { Extension, textblockTypeInputRule } from "@tiptap/core";
9
+ import StarterKit from "@tiptap/starter-kit";
10
+ import Heading from "@tiptap/extension-heading";
11
+ import Bold from "@tiptap/extension-bold";
12
+ import Italic from "@tiptap/extension-italic";
13
+ import Link from "@tiptap/extension-link";
14
+ import Blockquote from "@tiptap/extension-blockquote";
15
+ import { BulletList, OrderedList } from "@tiptap/extension-list";
16
+ /** Keep the mark in the schema; add no way to create it when `on` is false. */
17
+ function gateMarkConfig(on) {
18
+ return {
19
+ addInputRules() { return on ? this.parent?.() ?? [] : []; },
20
+ addPasteRules() { return on ? this.parent?.() ?? [] : []; },
21
+ addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
22
+ };
23
+ }
24
+ function gateNodeConfig(on) {
25
+ return {
26
+ addInputRules() { return on ? this.parent?.() ?? [] : []; },
27
+ addPasteRules() { return on ? this.parent?.() ?? [] : []; },
28
+ addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
29
+ };
30
+ }
31
+ const ALL_LEVELS = [1, 2, 3, 4, 5, 6];
32
+ export function richtextExtensions(allowed, hooks) {
33
+ const link = allowed.marks.has("link");
34
+ const levels = allowed.headingLevels;
35
+ return [
36
+ StarterKit.configure({
37
+ heading: false, bold: false, italic: false, link: false, blockquote: false,
38
+ bulletList: false, orderedList: false,
39
+ code: false, codeBlock: false, strike: false, underline: false, horizontalRule: false,
40
+ dropcursor: false, gapcursor: false, trailingNode: false,
41
+ }),
42
+ Heading.extend({
43
+ // Exact `#` counts, and only the allowed levels; `# ` does nothing when h1 is forbidden.
44
+ addInputRules() {
45
+ return levels.map((level) => textblockTypeInputRule({ find: new RegExp(`^(#{${level}})\\s$`), type: this.type, getAttributes: { level } }));
46
+ },
47
+ addKeyboardShortcuts() {
48
+ return Object.fromEntries(levels.map((level) => [`Mod-Alt-${level}`, () => this.editor.commands.toggleHeading({ level })]));
49
+ },
50
+ }).configure({ levels: ALL_LEVELS }),
51
+ Bold.extend(gateMarkConfig(allowed.marks.has("bold"))),
52
+ Italic.extend(gateMarkConfig(allowed.marks.has("italic"))),
53
+ Link.extend({
54
+ ...gateMarkConfig(link),
55
+ // href only (spec §3): no target, rel or class in the stored JSON.
56
+ addAttributes() { return { href: { default: null } }; },
57
+ }).configure({
58
+ openOnClick: false,
59
+ autolink: link,
60
+ linkOnPaste: link,
61
+ // Site-relative targets are links too; the site renderer's scheme filter is the safety net.
62
+ isAllowedUri: (url, ctx) => /^[/#?.]/.test(url) || ctx.defaultValidate(url),
63
+ }),
64
+ Blockquote.extend(gateNodeConfig(allowed.nodes.has("blockquote"))),
65
+ BulletList.extend(gateNodeConfig(allowed.nodes.has("bulletList"))),
66
+ OrderedList.extend(gateNodeConfig(allowed.nodes.has("orderedList"))),
67
+ Extension.create({
68
+ name: "smoodlyLinkShortcut",
69
+ addKeyboardShortcuts() {
70
+ return link ? { "Mod-k": () => { hooks.onLink(); return true; } } : {};
71
+ },
72
+ }),
73
+ ];
74
+ }
@@ -0,0 +1,22 @@
1
+ import type { Allowed } from "../../toolset.ts";
2
+ export type PmJSON = {
3
+ type: string;
4
+ attrs?: Record<string, unknown>;
5
+ content?: PmJSON[];
6
+ text?: string;
7
+ marks?: {
8
+ type: string;
9
+ attrs?: Record<string, unknown>;
10
+ }[];
11
+ };
12
+ /** ProseMirror's Slice.toJSON() shape. */
13
+ export type SliceJSON = {
14
+ content?: PmJSON[] | null;
15
+ openStart?: number;
16
+ openEnd?: number;
17
+ };
18
+ /** The allowed level closest to `level`; the smaller number on a tie;
19
+ * null when no heading is allowed. */
20
+ export declare function clampLevel(level: number, levels: number[]): number | null;
21
+ export declare function gateNodes(nodes: PmJSON[], allowed: Allowed): PmJSON[];
22
+ export declare function gatePastedSlice(slice: SliceJSON, allowed: Allowed): SliceJSON;
@@ -0,0 +1,59 @@
1
+ /** The allowed level closest to `level`; the smaller number on a tie;
2
+ * null when no heading is allowed. */
3
+ export function clampLevel(level, levels) {
4
+ if (levels.length === 0)
5
+ return null;
6
+ let best = levels[0];
7
+ for (const l of levels) {
8
+ const d = Math.abs(l - level);
9
+ const bd = Math.abs(best - level);
10
+ if (d < bd || (d === bd && l < best))
11
+ best = l;
12
+ }
13
+ return best;
14
+ }
15
+ export function gateNodes(nodes, allowed) {
16
+ return nodes.flatMap((n) => gateNode(n, allowed));
17
+ }
18
+ function gateNode(n, allowed) {
19
+ if (n.type === "text") {
20
+ const marks = (n.marks ?? []).filter((m) => allowed.marks.has(m.type));
21
+ const { marks: _drop, ...rest } = n;
22
+ return [marks.length > 0 ? { ...rest, marks } : rest];
23
+ }
24
+ const content = n.content ? gateNodes(n.content, allowed) : undefined;
25
+ const withContent = (node) => (content ? { ...node, content } : node);
26
+ if (n.type === "heading") {
27
+ const level = clampLevel(Number(n.attrs?.level ?? 1), allowed.headingLevels);
28
+ if (level === null)
29
+ return [withContent({ type: "paragraph" })];
30
+ return [withContent({ ...n, attrs: { ...n.attrs, level } })];
31
+ }
32
+ if (n.type === "blockquote" && !allowed.nodes.has("blockquote"))
33
+ return content ?? [];
34
+ if ((n.type === "bulletList" && !allowed.nodes.has("bulletList")) || (n.type === "orderedList" && !allowed.nodes.has("orderedList"))) {
35
+ return (content ?? []).flatMap((item) => item.content ?? []);
36
+ }
37
+ return [withContent(n)];
38
+ }
39
+ /** Depth of the first (or last) child chain, counting every non-text node. */
40
+ function edgeDepth(nodes, side) {
41
+ let depth = 0;
42
+ let list = nodes;
43
+ while (list.length > 0) {
44
+ const n = side === "first" ? list[0] : list[list.length - 1];
45
+ if (n.type === "text")
46
+ break;
47
+ depth++;
48
+ list = n.content ?? [];
49
+ }
50
+ return depth;
51
+ }
52
+ export function gatePastedSlice(slice, allowed) {
53
+ const content = gateNodes(slice.content ?? [], allowed);
54
+ return {
55
+ content,
56
+ openStart: Math.min(slice.openStart ?? 0, edgeDepth(content, "first")),
57
+ openEnd: Math.min(slice.openEnd ?? 0, edgeDepth(content, "last")),
58
+ };
59
+ }
@@ -0,0 +1,17 @@
1
+ import type { AdminOps, OpResult } from "../ops.ts";
2
+ import { type AssetRecord } from "../../media.ts";
3
+ export type Measured = {
4
+ width?: number;
5
+ height?: number;
6
+ duration?: number;
7
+ poster?: Blob;
8
+ };
9
+ export type UploadDeps = {
10
+ put(url: string, file: Blob, mime: string): Promise<void>;
11
+ measure(file: Blob, mime: string): Promise<Measured>;
12
+ };
13
+ export declare function uploadFile(ops: AdminOps["assets"], file: File, deps: UploadDeps): Promise<OpResult<AssetRecord>>;
14
+ /** A raw PUT to the signed URL: the body is the file, the type its mime,
15
+ * and the year-long cache-control Storage serves back (without the
16
+ * header a signed upload lands as `no-cache`). */
17
+ export declare function putSigned(url: string, file: Blob, mime: string): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { MEDIA_CACHE_SECONDS } from "../../media.js";
2
+ const stem = (name) => name.replace(/\.[^.]+$/, "");
3
+ export async function uploadFile(ops, file, deps) {
4
+ const begun = await ops.beginUpload({ name: file.name, mime: file.type, size: file.size });
5
+ if (!begun.ok)
6
+ return begun;
7
+ try {
8
+ await deps.put(begun.data.uploadUrl, file, file.type);
9
+ }
10
+ catch (e) {
11
+ return { ok: false, code: "internal", message: `smoodly: upload failed — ${e instanceof Error ? e.message : String(e)}` };
12
+ }
13
+ const m = await deps.measure(file, file.type);
14
+ let posterId;
15
+ if (m.poster) {
16
+ const poster = await uploadFile(ops, new File([m.poster], `${stem(file.name)}-poster.jpg`, { type: "image/jpeg" }), deps);
17
+ if (poster.ok)
18
+ posterId = poster.data.id;
19
+ }
20
+ return ops.finishUpload(begun.data.id, { width: m.width, height: m.height, duration: m.duration, posterId });
21
+ }
22
+ /** A raw PUT to the signed URL: the body is the file, the type its mime,
23
+ * and the year-long cache-control Storage serves back (without the
24
+ * header a signed upload lands as `no-cache`). */
25
+ export async function putSigned(url, file, mime) {
26
+ const res = await fetch(url, {
27
+ method: "PUT",
28
+ headers: { "content-type": mime, "x-upsert": "false", "cache-control": `max-age=${MEDIA_CACHE_SECONDS}` },
29
+ body: file,
30
+ });
31
+ if (!res.ok)
32
+ throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);
33
+ }
@@ -1,4 +1,4 @@
1
- export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult } from "./ops.ts";
1
+ export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult, AssetUsageItem } 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
4
  export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";
@@ -9,4 +9,3 @@ export { serializeAdminConfig } from "./serialize.ts";
9
9
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
10
10
  export * from "./tree-ops.ts";
11
11
  export { validateFields, type FieldError } from "./validate.ts";
12
- export { plainToRichtext, richtextToPlain } from "./richtext.ts";
@@ -7,4 +7,3 @@ export { supabaseAdminAuth } from "./supabase-auth.js";
7
7
  export { serializeAdminConfig } from "./serialize.js";
8
8
  export * from "./tree-ops.js";
9
9
  export { validateFields } from "./validate.js";
10
- export { plainToRichtext, richtextToPlain } from "./richtext.js";
@@ -1,4 +1,4 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  import type { ResolvedConfig } from "../../config.ts";
3
3
  import type { AdminOpAction } from "../ops.ts";
4
4
  import type { AuthConfig } from "../shell/session.ts";
@@ -7,12 +7,8 @@ export declare function createSmoodlyAdmin(input: {
7
7
  op: AdminOpAction;
8
8
  auth: () => AuthConfig;
9
9
  }): {
10
- AdminPage: ({ params }: {
11
- params: Promise<{
12
- segments?: string[];
13
- }>;
14
- }) => Promise<ReactElement>;
10
+ AdminPage: () => null;
15
11
  AdminLayout: ({ children }: {
16
12
  children: ReactNode;
17
- }) => ReactElement;
13
+ }) => import("react").JSX.Element;
18
14
  };
@@ -4,16 +4,25 @@ import { AdminApp } from "../shell/AdminApp.js";
4
4
  import { AdminStyles } from "../ui/theme.js";
5
5
  export function createSmoodlyAdmin(input) {
6
6
  const registry = serializeAdminConfig(input.config);
7
- async function AdminPage({ params }) {
8
- const { segments = [] } = await params;
9
- return _jsx(AdminApp, { registry: registry, segments: segments, op: input.op, auth: input.auth() });
7
+ /** The catch-all page only makes every /admin URL exist. The shell is
8
+ * mounted by the layout, not here: a server action that revalidates
9
+ * (publish, delete, a save of published content) answers with a
10
+ * re-rendered tree for the URL it was posted to, and Next remounts a
11
+ * page whose dynamic segment changed since the document load, while a
12
+ * layout persists (spec 2026-09-08 client routing, §1 amendment). */
13
+ function AdminPage() {
14
+ return null;
10
15
  }
11
16
  /** The admin owns the viewport: the whole UI is a fixed three-column
12
17
  * shell that scrolls internally, never the host document. The design
13
18
  * system's tokens ride along in one <style> — the host app imports
14
- * nothing (see ui/theme.tsx). */
19
+ * nothing (see ui/theme.tsx). The shell reads its route from the URL
20
+ * on mount (shell/router.ts); `children` is the page's nothing.
21
+ * suppressHydrationWarning: the theme boot script sets `data-theme`
22
+ * on this element before React hydrates, on purpose (no white flash
23
+ * for a dark-mode editor), so the attribute differs from the server's. */
15
24
  function AdminLayout({ children }) {
16
- return (_jsxs("div", { className: "sm-admin", children: [_jsx(AdminStyles, {}), children] }));
25
+ return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
17
26
  }
18
27
  return { AdminPage, AdminLayout };
19
28
  }
@@ -1,6 +1,7 @@
1
1
  import type { ResolvedConfig } from "../../config.ts";
2
2
  import type { EntryStore } from "../../entry-store.ts";
3
3
  import type { PageStore } from "../../store.ts";
4
+ import type { AssetStore } from "../../asset-store.ts";
4
5
  import { type AdminAuth } from "../auth.ts";
5
6
  import type { AdminOpAction } from "../ops.ts";
6
7
  export declare function createAdminOpHandler(deps: {
@@ -9,4 +10,6 @@ export declare function createAdminOpHandler(deps: {
9
10
  entries: EntryStore;
10
11
  auth: AdminAuth;
11
12
  href?: (path: string, locale: string) => string;
13
+ /** The media library; without it the assets ops answer "no media store". */
14
+ assets?: AssetStore;
12
15
  }): AdminOpAction;
@@ -12,6 +12,7 @@ export function createAdminOpHandler(deps) {
12
12
  pages: deps.pages,
13
13
  entries: deps.entries,
14
14
  href: deps.href,
15
+ assets: deps.assets,
15
16
  effects: {
16
17
  // updateTag, not revalidateTag(tag, profile): the profile form is
17
18
  // stale-while-revalidate, so the first request after publish still
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
2
  import type { EntryStore } from "../entry-store.ts";
3
+ import type { AssetStore } from "../asset-store.ts";
3
4
  import type { PageStore } from "../store.ts";
4
5
  import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
5
6
  export type AdminEffects = {
@@ -17,6 +18,8 @@ export declare function createAdminOps(deps: {
17
18
  effects?: AdminEffects;
18
19
  /** Locale-relative CMS path → site URL (the renderer's seam, applied here for the admin). Default: identity. */
19
20
  href?: (path: string, locale: string) => string;
21
+ /** The media library; absent = every assets op answers "no media store". */
22
+ assets?: AssetStore;
20
23
  }): AdminOps;
21
24
  /** Path-dispatch for the one-server-action seam. Own-property checks only —
22
25
  * "constructor.constructor" and prototype paths must not resolve. */