wiki-formant 0.49.0 → 0.51.0

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 CHANGED
@@ -649,7 +649,7 @@ export const POST = uploadHandler({
649
649
  });
650
650
  ```
651
651
 
652
- The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget, keyed on `authorize`'s id and address. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers.
652
+ The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget, keyed on `authorize`'s id and address. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers. `normalizeImage(bytes, { maxPixels, encode })` is the same sniff, cap and re-encode without the route, for images that arrive another way – a server action, or a picture copied in from another site; it resolves to `{ buffer, format, contentType }` and throws a message fit to show the uploader.
653
653
 
654
654
  ## Editor nodes
655
655
 
@@ -668,16 +668,19 @@ The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant
668
668
 
669
669
  `createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
670
670
 
671
- `toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
672
-
673
- `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
671
+ `toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". `WikiToolbar` lays them out the way three wikis had each assembled them by hand: the format buttons, Remove link while the cursor is in a link, upload and embed, the insert buttons, and the table controls while the cursor is in a table. Every button is a `ToolbarButton` (named, and `aria-pressed`, since these are toggles); the link button opens the address field and reads as on while it is open. Which buttons, icons and classes are yours; a toolbar shaped differently builds from `ToolbarButton` and `runTableAction` directly.
674
672
 
675
673
  ```tsx
676
- const url = useUrlPrompt(editor, { resolveMapUrl });
677
- <ToolbarButton label="Link" pressed={url.kind === 'link'} onPress={() => url.toggle('link')}>…</ToolbarButton>
678
- {url.kind && <input autoFocus value={url.value} onChange={e => url.setValue(e.target.value)} onKeyDown={url.onKeyDown} />}
674
+ <WikiToolbar editor={editor} isActive={isActive} prompt={url} actions={FORMAT} trailing={INSERT} onUpload={triggerUpload} embed
675
+ icons={{ ...ICONS, unlink: <Unlink />, upload: <Upload />, embed: <Globe /> }}
676
+ classNames={{ toolbar: 'toolbar', button: 'toolbar-btn', pressed: 'toolbar-btn-on', divider: 'toolbar-divider' }} />
677
+ <UrlPromptField prompt={url} classNames={{ row: 'row', input: 'input', apply: 'btn', cancel: 'btn-quiet' }} />
679
678
  ```
680
679
 
680
+ `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
681
+
682
+ `UrlPromptField` is its markup: an address input on which Enter applies and Escape closes, and Apply and Cancel.
683
+
681
684
  A link is stored as its `href` and the `link` class, nothing else. Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
682
685
 
683
686
  The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
@@ -686,6 +689,36 @@ The redirect resolves through the wiki's own route, because the editor cannot re
686
689
  export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) });
687
690
  ```
688
691
 
692
+ ## Block editor
693
+
694
+ `wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once; the look arrives as a kit.
695
+
696
+ ```tsx
697
+ const KIT: BlockEditorKit = {
698
+ classNames: { list: 'stack', card: 'block-card', action: 'icon-btn', input: 'input', row: 'row', … },
699
+ icons: { add: <Plus />, up: <ChevronUp />, down: <ChevronDown />, duplicate: <Copy />, remove: <Trash2 /> },
700
+ Choice: ToggleGroup, // optional: a <select> otherwise
701
+ Menu: PopoverMenu, // optional: an inline panel otherwise
702
+ };
703
+ const SCHEMA: BlockSchema<Block> = {
704
+ meta: type => BLOCK_META[type], create: createBlock, duplicate: duplicateBlock,
705
+ types: PAGE_TYPES, nestedTypes: ATOMIC_TYPES, single: ['infobox'],
706
+ renderBody: (block, onUpdate) => {
707
+ switch (block.type) {
708
+ case 'codeTabs': return <CodeTabsFields block={block} onUpdate={onUpdate} />;
709
+ case 'columns': return <ColumnsFields block={block} onUpdate={onUpdate} />;
710
+ case 'daoTimeline': return <DaoTimelineEdit block={block} onUpdate={onUpdate} />;
711
+ …
712
+ }
713
+ },
714
+ };
715
+ <BlockEditor blocks={blocks} onChange={setBlocks} schema={SCHEMA} kit={KIT} />
716
+ ```
717
+
718
+ The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
719
+
720
+ The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
721
+
689
722
  ## Analytics
690
723
 
691
724
  Page views and events, counted in the site's own Postgres instead of a hosted
@@ -0,0 +1,203 @@
1
+ import { type ComponentType, type ReactElement, type ReactNode } from 'react';
2
+ import { type BannerBlock, type CodeTabsBlock, type ColumnsBlock, type InfoboxBlock, type LinkGridBlock, type ReferencesBlock, type StatsBlock } from './blocks.js';
3
+ export interface BlockEditorClassNames {
4
+ /** A page's block list. */
5
+ list?: string;
6
+ /** A column's or an infobox's. */
7
+ nestedList?: string;
8
+ /** The line an empty list shows. */
9
+ empty?: string;
10
+ /** A block. It carries `data-selected` while it is the one being edited, and `data-nested` inside a container. */
11
+ card?: string;
12
+ cardHead?: string;
13
+ cardLabel?: string;
14
+ cardActions?: string;
15
+ /** Every icon button: a card's actions, add-below, remove. */
16
+ action?: string;
17
+ /** The Add block button at the foot of a list. */
18
+ addBlock?: string;
19
+ /** The default `Add` button: Add tab, Add link. */
20
+ add?: string;
21
+ /** The default `Menu`'s wrapper and panel. */
22
+ menuAnchor?: string;
23
+ menu?: string;
24
+ menuItem?: string;
25
+ menuText?: string;
26
+ menuHint?: string;
27
+ /** A vertical group of fields. */
28
+ stack?: string;
29
+ /** A row of inputs ending in a remove button. */
30
+ row?: string;
31
+ /** A group inside a block: a link group, a column. */
32
+ nest?: string;
33
+ /** What holds the columns of a columns block. */
34
+ columns?: string;
35
+ /** The default `Field`'s label. */
36
+ field?: string;
37
+ input?: string;
38
+ /** Added to a code tab's language select. */
39
+ lang?: string;
40
+ /** Added to a code tab's textarea. */
41
+ code?: string;
42
+ /** Added to a stat's figure input. */
43
+ figure?: string;
44
+ /** Added to a count input. */
45
+ number?: string;
46
+ /** Row numbers, hints and notes. */
47
+ muted?: string;
48
+ }
49
+ export interface BlockEditorIcons {
50
+ add: ReactNode;
51
+ up: ReactNode;
52
+ down: ReactNode;
53
+ duplicate: ReactNode;
54
+ remove: ReactNode;
55
+ }
56
+ export interface FieldProps {
57
+ label: string;
58
+ hint?: string;
59
+ children: ReactNode;
60
+ }
61
+ /** One of a few values: a toggle group, a chip row or a select. */
62
+ export interface ChoiceProps {
63
+ label: string;
64
+ value: string;
65
+ options: ReadonlyArray<{
66
+ value: string;
67
+ label: string;
68
+ }>;
69
+ onChange: (value: string) => void;
70
+ }
71
+ /**
72
+ * The add-block menu around its trigger. `trigger` is the button the editor
73
+ * drew; a popover takes it as its anchor, an inline panel renders it first.
74
+ */
75
+ export interface MenuProps {
76
+ open: boolean;
77
+ onOpenChange: (open: boolean) => void;
78
+ trigger: ReactElement;
79
+ /** The icon in a block's head that adds under it, rather than the list's foot button. */
80
+ below: boolean;
81
+ children: ReactNode;
82
+ }
83
+ export interface AddProps {
84
+ onPress: () => void;
85
+ children: ReactNode;
86
+ }
87
+ export interface BlockEditorKit {
88
+ classNames: BlockEditorClassNames;
89
+ icons: BlockEditorIcons;
90
+ Field?: ComponentType<FieldProps>;
91
+ Choice?: ComponentType<ChoiceProps>;
92
+ Menu?: ComponentType<MenuProps>;
93
+ Add?: ComponentType<AddProps>;
94
+ }
95
+ export type EditableBlock = {
96
+ id: string;
97
+ type: string;
98
+ };
99
+ export interface BlockMeta {
100
+ label: string;
101
+ icon?: ReactNode;
102
+ /** The line under the label in the add menu. */
103
+ hint?: string;
104
+ }
105
+ export interface BlockSchema<B extends EditableBlock> {
106
+ /** Undefined for a type this build does not know: it is shown by its raw type and kept as it is. */
107
+ meta: (type: B['type']) => BlockMeta | undefined;
108
+ create: (type: B['type']) => B;
109
+ /** A copy with fresh ids at every level. */
110
+ duplicate: (block: B) => B;
111
+ /**
112
+ * A block's fields. The wiki's own `switch`, so it stays exhaustive over its
113
+ * union. Null for a type with no fields here: the card says it is kept as is.
114
+ */
115
+ renderBody: (block: B, onUpdate: (next: B) => void) => ReactNode;
116
+ /** What a page's add menus offer, in their order. */
117
+ types: readonly B['type'][];
118
+ /** What a column's and an infobox's offer: never a container. */
119
+ nestedTypes: readonly B['type'][];
120
+ /** Types a list holds at most one of, such as an infobox an article lifts into its rail. */
121
+ single?: readonly B['type'][];
122
+ }
123
+ export interface BlockEditorProps<B extends EditableBlock> {
124
+ blocks: B[];
125
+ onChange: (blocks: B[]) => void;
126
+ schema: BlockSchema<B>;
127
+ kit: BlockEditorKit;
128
+ }
129
+ /**
130
+ * A page's blocks, edited: each in a card with its add-below, move, duplicate
131
+ * and delete controls, and an add menu at the foot. Columns and infoboxes hold
132
+ * the same list, offering `nestedTypes`.
133
+ */
134
+ export declare function BlockEditor<B extends EditableBlock>({ blocks, onChange, schema, kit }: BlockEditorProps<B>): import("react").JSX.Element;
135
+ /** A labelled field in the kit's style, for a wiki's own block editors as well as these. */
136
+ export declare function Field(props: FieldProps): import("react").JSX.Element;
137
+ /** One of a few values in the kit's style; a `<select>` when the kit has none. */
138
+ export declare function Choice(props: ChoiceProps): import("react").JSX.Element;
139
+ /** A count clamped to 1…`MAX_BLOCK_ROWS`, the cap the write path enforces. */
140
+ export declare function LimitField({ label, value, onChange }: {
141
+ label?: string;
142
+ value: number | undefined;
143
+ onChange: (n: number) => void;
144
+ }): import("react").JSX.Element;
145
+ /**
146
+ * `code` is source text, never HTML: every view escapes it. A stored language
147
+ * outside `langs` stays selectable rather than reading as the first option and
148
+ * being overwritten on the next save.
149
+ */
150
+ export declare function CodeTabsFields({ block, onUpdate, langs, defaultLang, }: {
151
+ block: CodeTabsBlock;
152
+ onUpdate: (block: CodeTabsBlock) => void;
153
+ langs?: readonly string[];
154
+ defaultLang?: string;
155
+ }): import("react").JSX.Element;
156
+ export declare function StatsFields({ block, onUpdate, hint }: {
157
+ block: StatsBlock;
158
+ onUpdate: (block: StatsBlock) => void;
159
+ hint?: string;
160
+ }): import("react").JSX.Element;
161
+ /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
162
+ export declare function ReferencesFields({ block, onUpdate, title, noun, hint, }: {
163
+ block: ReferencesBlock;
164
+ onUpdate: (block: ReferencesBlock) => void;
165
+ title?: string;
166
+ noun?: string;
167
+ hint?: string;
168
+ }): import("react").JSX.Element;
169
+ export declare function LinkGridFields({ block, onUpdate }: {
170
+ block: LinkGridBlock;
171
+ onUpdate: (block: LinkGridBlock) => void;
172
+ }): import("react").JSX.Element;
173
+ export declare function BannerFields({ block, onUpdate }: {
174
+ block: BannerBlock;
175
+ onUpdate: (block: BannerBlock) => void;
176
+ }): import("react").JSX.Element;
177
+ /** A page a page list can pick. `detail` is shown after the title, such as its section. */
178
+ export interface PageOption {
179
+ id: string;
180
+ title: string;
181
+ detail?: string;
182
+ }
183
+ /**
184
+ * The picked pages in their order, each movable and removable. With `pages`
185
+ * the rest are offered in a picker; without, an id is typed in.
186
+ */
187
+ export declare function PageListFields<B extends {
188
+ pageIds: string[];
189
+ }>({ block, onUpdate, pages }: {
190
+ block: B;
191
+ onUpdate: (block: B) => void;
192
+ pages?: readonly PageOption[];
193
+ }): import("react").JSX.Element;
194
+ /** Two to four columns, each a block list offering the schema's `nestedTypes`. */
195
+ export declare function ColumnsFields<A extends EditableBlock>({ block, onUpdate }: {
196
+ block: ColumnsBlock<A>;
197
+ onUpdate: (block: ColumnsBlock<A>) => void;
198
+ }): import("react").JSX.Element;
199
+ /** An infobox's blocks, offering the schema's `nestedTypes`. */
200
+ export declare function InfoboxFields<A extends EditableBlock>({ block, onUpdate }: {
201
+ block: InfoboxBlock<A>;
202
+ onUpdate: (block: InfoboxBlock<A>) => void;
203
+ }): import("react").JSX.Element;
@@ -0,0 +1,218 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // block-editor.tsx — the block list an author edits, and the fields of the core
4
+ // block types.
5
+ //
6
+ // Three wikis had written this, 500 to 700 lines each, and the copies had
7
+ // drifted the way copies do: one dropped a stored code language that was not
8
+ // in its list, one let a typed count run past the row cap, one named every
9
+ // remove button "Remove", one never highlighted the block being edited. The
10
+ // behaviour lives here once. The look does not: each wiki draws its inputs,
11
+ // toggles and menus with its own design system, so those arrive as a KIT —
12
+ // class names, icons, and four optional components — and the markup here is
13
+ // bare semantic HTML wearing whatever the kit says.
14
+ //
15
+ // The dispatch stays with the wiki. `renderBody` is a `switch` over its own
16
+ // closed union, as S4 of the workspace's STRUCTURE.md requires, so a new block
17
+ // type is a compile error there; its cases hand the core types to the field
18
+ // editors below and draw its native types themselves.
19
+ //
20
+ // The kit and the schema reach every nested list through context, which is
21
+ // what lets a column or an infobox hold a list without being handed the whole
22
+ // configuration. Keep both stable — a module constant or a `useMemo` — or every
23
+ // card re-renders on every keystroke.
24
+ import { createContext, memo, useCallback, useContext, useMemo, useState, } from 'react';
25
+ import { BlockActions, useBlockOperations } from './react.js';
26
+ import { CODE_LANGS, DEFAULT_LANG, } from './blocks.js';
27
+ import { BANNER_VARIANTS } from './text.js';
28
+ import { MAX_BLOCK_ROWS } from './validation.js';
29
+ import { cx } from './html.js';
30
+ const Context = createContext(null);
31
+ function useEditorContext() {
32
+ const ctx = useContext(Context);
33
+ if (!ctx)
34
+ throw new Error('A block field editor must render inside <BlockEditor>.');
35
+ return ctx;
36
+ }
37
+ /**
38
+ * A page's blocks, edited: each in a card with its add-below, move, duplicate
39
+ * and delete controls, and an add menu at the foot. Columns and infoboxes hold
40
+ * the same list, offering `nestedTypes`.
41
+ */
42
+ export function BlockEditor({ blocks, onChange, schema, kit }) {
43
+ const value = useMemo(() => ({ kit, schema: schema }), [kit, schema]);
44
+ return (_jsx(Context.Provider, { value: value, children: _jsx(BlockList, { blocks: blocks, onChange: onChange }) }));
45
+ }
46
+ function BlockList({ blocks, onChange, nested = false }) {
47
+ const { kit, schema } = useEditorContext();
48
+ const c = kit.classNames;
49
+ const ops = useBlockOperations(blocks, onChange, schema);
50
+ const base = nested ? schema.nestedTypes : schema.types;
51
+ // A string, so the offer is recomputed — and every card re-rendered — only
52
+ // when a single-use type comes or goes, not on every keystroke.
53
+ const held = (schema.single ?? []).filter(t => blocks.some(b => b.type === t)).join(' ');
54
+ const offered = useMemo(() => base.filter(t => !held.split(' ').includes(t)), [base, held]);
55
+ const insertNew = useCallback((type) => ops.insert(schema.create(type)), [ops.insert, schema]);
56
+ return (_jsxs("div", { className: nested ? c.nestedList : c.list, children: [blocks.length === 0 && _jsx("p", { className: c.empty, children: nested ? 'Empty.' : 'Nothing here yet. Add the first block.' }), blocks.map((block, i) => (_jsx(BlockCard, { block: block, index: i, total: blocks.length, selected: ops.selectedIndex === i, nested: nested, offered: offered, update: ops.update, move: ops.move, duplicate: ops.duplicate, remove: ops.remove, insert: ops.insert, select: ops.setSelectedIndex }, block.id))), _jsx(InsertMenu, { types: offered, onPick: insertNew })] }));
57
+ }
58
+ // Memoised on the list's ref-stable callbacks, so a keystroke in one block does
59
+ // not re-render every other block's editor.
60
+ const BlockCard = memo(function BlockCard({ block, index, total, selected, nested, offered, update, move, duplicate, remove, insert, select }) {
61
+ const { kit, schema } = useEditorContext();
62
+ const c = kit.classNames;
63
+ const onUpdate = useCallback((next) => update(index, next), [update, index]);
64
+ const onSelect = useCallback(() => select(index), [select, index]);
65
+ const meta = schema.meta(block.type);
66
+ const label = meta?.label ?? block.type;
67
+ const body = meta ? schema.renderBody(block, onUpdate) : null;
68
+ return (_jsxs("section", { className: c.card, "data-selected": selected || undefined, "data-nested": nested || undefined, onFocusCapture: onSelect, onClick: onSelect, children: [_jsxs("div", { className: c.cardHead, children: [_jsxs("span", { className: c.cardLabel, children: [meta?.icon, label] }), _jsxs("span", { className: c.cardActions, children: [_jsx(InsertMenu, { below: true, types: offered, onPick: type => insert(schema.create(type), index + 1) }), _jsx(BlockActions, { index: index, total: total, ops: { move, duplicate, remove }, icons: kit.icons, className: c.cardActions, buttonClassName: c.action, blockLabel: label })] })] }), body ?? _jsx("p", { className: c.muted, children: "Kept as it is \u2013 this editor has no fields for it." })] }));
69
+ });
70
+ function InsertMenu({ types, onPick, below = false }) {
71
+ const { kit, schema } = useEditorContext();
72
+ const c = kit.classNames;
73
+ const [open, setOpen] = useState(false);
74
+ const Menu = kit.Menu ?? InlineMenu;
75
+ const toggle = () => setOpen(o => !o);
76
+ const trigger = below ? (_jsx("button", { type: "button", className: c.action, title: "Add a block below", "aria-label": "Add a block below", "aria-expanded": open, onClick: toggle, children: kit.icons.add })) : (_jsxs("button", { type: "button", className: c.addBlock, "aria-expanded": open, onClick: toggle, children: [kit.icons.add, "Add block"] }));
77
+ return (_jsx(Menu, { open: open, onOpenChange: setOpen, trigger: trigger, below: below, children: types.map(type => {
78
+ const meta = schema.meta(type);
79
+ return (_jsxs("button", { type: "button", className: c.menuItem, onClick: e => {
80
+ // Inside a card, a pick is not also a click on the card.
81
+ e.stopPropagation();
82
+ onPick(type);
83
+ setOpen(false);
84
+ }, children: [meta?.icon, _jsxs("span", { className: c.menuText, children: [_jsx("span", { children: meta?.label ?? type }), meta?.hint && _jsx("span", { className: c.menuHint, children: meta.hint })] })] }, type));
85
+ }) }));
86
+ }
87
+ function InlineMenu({ open, trigger, children }) {
88
+ const c = useEditorContext().kit.classNames;
89
+ return (_jsxs("span", { className: c.menuAnchor, children: [trigger, open && _jsx("span", { className: c.menu, children: children })] }));
90
+ }
91
+ // ---- the kit's parts, resolved ----------------------------------------------
92
+ /** A labelled field in the kit's style, for a wiki's own block editors as well as these. */
93
+ export function Field(props) {
94
+ const { Field: Custom, classNames: c } = useEditorContext().kit;
95
+ if (Custom)
96
+ return _jsx(Custom, { ...props });
97
+ return (_jsxs("label", { className: c.field, children: [_jsx("span", { children: props.label }), props.children, props.hint && _jsx("small", { className: c.muted, children: props.hint })] }));
98
+ }
99
+ /** One of a few values in the kit's style; a `<select>` when the kit has none. */
100
+ export function Choice(props) {
101
+ const { Choice: Custom, classNames: c } = useEditorContext().kit;
102
+ if (Custom)
103
+ return _jsx(Custom, { ...props });
104
+ return (_jsx(Field, { label: props.label, children: _jsx("select", { className: c.input, value: props.value, onChange: e => props.onChange(e.target.value), children: props.options.map(o => (_jsx("option", { value: o.value, children: o.label }, o.value))) }) }));
105
+ }
106
+ function Add({ onPress, children }) {
107
+ const { Add: Custom, classNames: c, icons } = useEditorContext().kit;
108
+ if (Custom)
109
+ return _jsx(Custom, { onPress: onPress, children: children });
110
+ return (_jsxs("button", { type: "button", className: c.add, onClick: onPress, children: [icons.add, children] }));
111
+ }
112
+ function Remove({ label, onPress }) {
113
+ const { classNames: c, icons } = useEditorContext().kit;
114
+ return (_jsx("button", { type: "button", className: c.action, title: label, "aria-label": label, onClick: onPress, children: icons.remove }));
115
+ }
116
+ /** A text input. `optional` stores an emptied field as absent rather than `''`. */
117
+ function TextInput({ label, value, onChange, placeholder = label, optional = false, type = 'text', className, }) {
118
+ const c = useEditorContext().kit.classNames;
119
+ return (_jsx("input", { type: type, className: cx(c.input, className), "aria-label": label, placeholder: placeholder, value: value ?? '', onChange: e => onChange(optional ? e.target.value.trim() ? e.target.value : undefined : e.target.value) }));
120
+ }
121
+ const replaceAt = (items, i, item) => items.map((x, j) => (j === i ? item : x));
122
+ const removeAt = (items, i) => items.filter((_, j) => j !== i);
123
+ const newId = () => crypto.randomUUID();
124
+ // ---- the core blocks' fields -------------------------------------------------
125
+ /** A count clamped to 1…`MAX_BLOCK_ROWS`, the cap the write path enforces. */
126
+ export function LimitField({ label = 'How many', value, onChange }) {
127
+ const c = useEditorContext().kit.classNames;
128
+ return (_jsx(Field, { label: label, children: _jsx("input", { type: "number", min: 1, max: MAX_BLOCK_ROWS, className: cx(c.input, c.number), value: value ?? '', onChange: e => onChange(Math.min(MAX_BLOCK_ROWS, Math.max(1, Math.floor(Number(e.target.value)) || 1))) }) }));
129
+ }
130
+ /**
131
+ * `code` is source text, never HTML: every view escapes it. A stored language
132
+ * outside `langs` stays selectable rather than reading as the first option and
133
+ * being overwritten on the next save.
134
+ */
135
+ export function CodeTabsFields({ block, onUpdate, langs = CODE_LANGS, defaultLang = DEFAULT_LANG, }) {
136
+ const c = useEditorContext().kit.classNames;
137
+ const tabs = block.tabs;
138
+ const set = (next) => onUpdate({ ...block, tabs: next });
139
+ return (_jsxs("div", { className: c.stack, children: [tabs.map((tab, i) => (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Tab label", value: tab.label, onChange: label => set(replaceAt(tabs, i, { ...tab, label: label ?? '' })) }), _jsx("select", { "aria-label": "Language", className: cx(c.input, c.lang), value: tab.language, onChange: e => set(replaceAt(tabs, i, { ...tab, language: e.target.value })), children: [...new Set([tab.language, ...langs])].map(l => (_jsx("option", { value: l, children: l }, l))) }), _jsx(Remove, { label: `Remove tab ${tab.label || i + 1}`, onPress: () => set(removeAt(tabs, i)) })] }), _jsx("textarea", { "aria-label": "Code", placeholder: "Code", rows: 6, spellCheck: false, className: cx(c.input, c.code), value: tab.code, onChange: e => set(replaceAt(tabs, i, { ...tab, code: e.target.value })) })] }, i))), _jsx(Add, { onPress: () => set([...tabs, { label: '', language: defaultLang, code: '' }]), children: "Add tab" })] }));
140
+ }
141
+ export function StatsFields({ block, onUpdate, hint }) {
142
+ const c = useEditorContext().kit.classNames;
143
+ const items = block.items;
144
+ const set = (next) => onUpdate({ ...block, items: next });
145
+ return (_jsxs("div", { className: c.stack, children: [_jsx(Choice, { label: "Per row", value: String(block.columns), options: ['2', '3', '4'].map(n => ({ value: n, label: n })), onChange: n => onUpdate({ ...block, columns: Number(n) }) }), items.map((item, i) => (_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Figure", className: c.figure, value: item.value, onChange: value => set(replaceAt(items, i, { ...item, value: value ?? '' })) }), _jsx(TextInput, { label: "What it counts", value: item.label, onChange: label => set(replaceAt(items, i, { ...item, label: label ?? '' })) }), _jsx(Remove, { label: `Remove figure ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }, item.id))), _jsx(Add, { onPress: () => set([...items, { id: newId(), value: '', label: '' }]), children: "Add figure" }), hint && _jsx("small", { className: c.muted, children: hint })] }));
146
+ }
147
+ /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
148
+ export function ReferencesFields({ block, onUpdate, title = 'References', noun = 'reference', hint, }) {
149
+ const c = useEditorContext().kit.classNames;
150
+ const items = block.items;
151
+ const set = (next) => onUpdate({ ...block, items: next });
152
+ return (_jsxs("div", { className: c.stack, children: [_jsx(Field, { label: "Heading", children: _jsx(TextInput, { label: "Heading", placeholder: title, optional: true, value: block.title, onChange: t => onUpdate({ ...block, title: t }) }) }), items.map((item, i) => (_jsxs("div", { className: c.row, children: [_jsxs("span", { className: c.muted, children: [i + 1, "."] }), _jsx(TextInput, { label: "Title, author, publisher", value: item.text, onChange: text => set(replaceAt(items, i, { ...item, text: text ?? '' })) }), _jsx(TextInput, { label: "Address", type: "url", placeholder: "https://", optional: true, value: item.url, onChange: url => set(replaceAt(items, i, { ...item, url })) }), _jsx(Remove, { label: `Remove ${noun} ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }, item.id))), _jsxs(Add, { onPress: () => set([...items, { id: newId(), text: '' }]), children: ["Add ", noun] }), hint && _jsx("small", { className: c.muted, children: hint })] }));
153
+ }
154
+ export function LinkGridFields({ block, onUpdate }) {
155
+ const c = useEditorContext().kit.classNames;
156
+ const groups = block.groups;
157
+ return (_jsxs("div", { className: c.stack, children: [_jsx(Field, { label: "Intro (optional)", children: _jsx(TextInput, { label: "Intro", placeholder: "A paragraph before the groups", optional: true, value: block.intro, onChange: intro => onUpdate({ ...block, intro }) }) }), groups.map((group, gi) => {
158
+ const setGroup = (next) => onUpdate({ ...block, groups: replaceAt(groups, gi, next) });
159
+ return (_jsxs("div", { className: cx(c.nest, c.stack), children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Heading", value: group.heading, onChange: heading => setGroup({ ...group, heading: heading ?? '' }) }), _jsx(Remove, { label: `Remove group ${group.heading || gi + 1}`, onPress: () => onUpdate({ ...block, groups: removeAt(groups, gi) }) })] }), _jsx(TextInput, { label: "Description", placeholder: "Description (optional)", optional: true, value: group.description ?? undefined, onChange: description => setGroup({ ...group, description }) }), group.links.map((link, li) => (_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Label", value: link.label, onChange: label => setGroup({ ...group, links: replaceAt(group.links, li, { ...link, label: label ?? '' }) }) }), _jsx(TextInput, { label: "Address", placeholder: "/path or https://", value: link.href, onChange: href => setGroup({ ...group, links: replaceAt(group.links, li, { ...link, href: href ?? '' }) }) }), _jsx(Remove, { label: `Remove link ${link.label || li + 1}`, onPress: () => setGroup({ ...group, links: removeAt(group.links, li) }) })] }, li))), _jsx(Add, { onPress: () => setGroup({ ...group, links: [...group.links, { label: '', href: '' }] }), children: "Add link" })] }, group.id));
160
+ }), _jsx(Add, { onPress: () => onUpdate({ ...block, groups: [...groups, { id: newId(), heading: '', links: [] }] }), children: "Add group" })] }));
161
+ }
162
+ export function BannerFields({ block, onUpdate }) {
163
+ const c = useEditorContext().kit.classNames;
164
+ return (_jsxs("div", { className: c.stack, children: [_jsx(Choice, { label: "Notice", value: block.variant, options: BANNER_VARIANTS, onChange: v => onUpdate({ ...block, variant: v }) }), _jsx(Field, { label: "Custom message (optional)", children: _jsx(TextInput, { label: "Custom message", placeholder: "Empty for the standard notice", optional: true, value: block.text, onChange: text => onUpdate({ ...block, text }) }) })] }));
165
+ }
166
+ /**
167
+ * The picked pages in their order, each movable and removable. With `pages`
168
+ * the rest are offered in a picker; without, an id is typed in.
169
+ */
170
+ export function PageListFields({ block, onUpdate, pages }) {
171
+ const { kit } = useEditorContext();
172
+ const c = kit.classNames;
173
+ const [typed, setTyped] = useState('');
174
+ const ids = block.pageIds;
175
+ const set = (pageIds) => onUpdate({ ...block, pageIds });
176
+ const byId = new Map(pages?.map(p => [p.id, p]));
177
+ const full = ids.length >= MAX_BLOCK_ROWS;
178
+ const add = (id) => {
179
+ if (id && !ids.includes(id) && !full)
180
+ set([...ids, id]);
181
+ };
182
+ const unpicked = pages?.filter(p => !ids.includes(p.id)) ?? [];
183
+ const ops = {
184
+ move: (from, to) => {
185
+ const next = removeAt(ids, from);
186
+ next.splice(to, 0, ids[from]);
187
+ set(next);
188
+ },
189
+ duplicate: () => { },
190
+ remove: (i) => set(removeAt(ids, i)),
191
+ };
192
+ return (_jsxs("div", { className: c.stack, children: [ids.map((id, i) => {
193
+ const page = byId.get(id);
194
+ return (_jsxs("div", { className: c.row, children: [_jsxs("span", { className: c.muted, children: [i + 1, "."] }), _jsxs("span", { children: [pages ? (page?.title ?? `No such page: ${id}`) : id, page?.detail && _jsxs("span", { className: c.muted, children: [" \u00B7 ", page.detail] })] }), _jsx(BlockActions, { index: i, total: ids.length, ops: ops, icons: { up: kit.icons.up, down: kit.icons.down, remove: kit.icons.remove }, className: c.cardActions, buttonClassName: c.action, blockLabel: "page" })] }, i));
195
+ }), pages && pages.length === 0 && _jsx("p", { className: c.muted, children: "This wiki has no pages to pick yet." }), !full && pages && unpicked.length > 0 && (_jsx(Field, { label: "Add a page", children: _jsxs("select", { className: c.input, value: "", onChange: e => add(e.target.value), children: [_jsx("option", { value: "", children: "Choose\u2026" }), unpicked.map(p => (_jsx("option", { value: p.id, children: p.detail ? `${p.title} · ${p.detail}` : p.title }, p.id)))] }) })), !full && !pages && (_jsxs("div", { className: c.row, children: [_jsx("input", { type: "text", className: c.input, "aria-label": "Page id", placeholder: "Page id", value: typed, onChange: e => setTyped(e.target.value), onKeyDown: e => {
196
+ if (e.key !== 'Enter')
197
+ return;
198
+ e.preventDefault();
199
+ add(typed.trim());
200
+ setTyped('');
201
+ } }), _jsx(Add, { onPress: () => {
202
+ add(typed.trim());
203
+ setTyped('');
204
+ }, children: "Add page" })] }))] }));
205
+ }
206
+ const GAPS = ['sm', 'md', 'lg'].map(v => ({ value: v, label: v }));
207
+ const ALIGNS = ['start', 'center', 'end', 'stretch'].map(v => ({ value: v, label: v }));
208
+ const MAX_COLUMNS = 4;
209
+ /** Two to four columns, each a block list offering the schema's `nestedTypes`. */
210
+ export function ColumnsFields({ block, onUpdate }) {
211
+ const c = useEditorContext().kit.classNames;
212
+ const columns = block.columns;
213
+ return (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(Choice, { label: "Gap", value: block.gap ?? 'md', options: GAPS, onChange: gap => onUpdate({ ...block, gap: gap }) }), _jsx(Choice, { label: "Align", value: block.align ?? 'start', options: ALIGNS, onChange: align => onUpdate({ ...block, align: align }) })] }), _jsx("div", { className: c.columns, children: columns.map((column, ci) => (_jsxs("div", { className: cx(c.nest, c.stack), children: [_jsxs("div", { className: c.cardHead, children: [_jsxs("span", { className: c.cardLabel, children: ["Column ", ci + 1] }), columns.length > 1 && _jsx(Remove, { label: `Remove column ${ci + 1}`, onPress: () => onUpdate({ ...block, columns: removeAt(columns, ci) }) })] }), _jsx(BlockList, { nested: true, blocks: column.blocks, onChange: blocks => onUpdate({ ...block, columns: replaceAt(columns, ci, { ...column, blocks: blocks }) }) })] }, column.id))) }), columns.length < MAX_COLUMNS && _jsx(Add, { onPress: () => onUpdate({ ...block, columns: [...columns, { id: newId(), blocks: [] }] }), children: "Add column" })] }));
214
+ }
215
+ /** An infobox's blocks, offering the schema's `nestedTypes`. */
216
+ export function InfoboxFields({ block, onUpdate }) {
217
+ return _jsx(BlockList, { nested: true, blocks: block.blocks, onChange: blocks => onUpdate({ ...block, blocks: blocks }) });
218
+ }
package/dist/blocks.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type RelativeTimeStyle } from './freshness.js';
2
2
  import type { BlockGroup } from './revisions.js';
3
+ import { type BannerVariant } from './text.js';
3
4
  /**
4
5
  * The languages a code block or code tab offers, and the one a new one opens
5
6
  * on. Two wikis carried this list character for character; a wiki with its own
@@ -50,6 +51,72 @@ export interface LinkGridGroup {
50
51
  description?: string | null;
51
52
  links: readonly LinkGridLink[];
52
53
  }
54
+ export type EditableCodeTab = CodeTab & {
55
+ language: string;
56
+ };
57
+ export type EditableStatItem = StatItem & {
58
+ id: string;
59
+ value: string;
60
+ };
61
+ export type EditableReferenceItem = ReferenceItem & {
62
+ id: string;
63
+ url?: string;
64
+ };
65
+ export type EditableLinkGridGroup = LinkGridGroup & {
66
+ id: string;
67
+ links: LinkGridLink[];
68
+ };
69
+ export interface ContentBlock {
70
+ id: string;
71
+ type: 'content';
72
+ text: string;
73
+ }
74
+ export interface CodeTabsBlock {
75
+ id: string;
76
+ type: 'codeTabs';
77
+ tabs: EditableCodeTab[];
78
+ }
79
+ export interface StatsBlock {
80
+ id: string;
81
+ type: 'stats';
82
+ items: EditableStatItem[];
83
+ columns: 2 | 3 | 4;
84
+ }
85
+ export interface LinkGridBlock {
86
+ id: string;
87
+ type: 'linkGrid';
88
+ intro?: string;
89
+ groups: EditableLinkGridGroup[];
90
+ }
91
+ export interface ReferencesBlock {
92
+ id: string;
93
+ type: 'references';
94
+ title?: string;
95
+ items: EditableReferenceItem[];
96
+ }
97
+ /** A Wikipedia-style notice. `text` replaces the variant's standard message. */
98
+ export interface BannerBlock {
99
+ id: string;
100
+ type: 'banner';
101
+ variant: BannerVariant;
102
+ text?: string;
103
+ }
104
+ /** `A` is the wiki's atomic union: a container never holds another container. */
105
+ export interface ColumnsBlock<A> {
106
+ id: string;
107
+ type: 'columns';
108
+ columns: {
109
+ id: string;
110
+ blocks: A[];
111
+ }[];
112
+ gap?: 'sm' | 'md' | 'lg';
113
+ align?: 'start' | 'center' | 'end' | 'stretch';
114
+ }
115
+ export interface InfoboxBlock<A> {
116
+ id: string;
117
+ type: 'infobox';
118
+ blocks: A[];
119
+ }
53
120
  export interface BlockTreeOptions<B> {
54
121
  /** Render one leaf block. Return `''` to drop it from the document. */
55
122
  atomic: (block: B) => string;
package/dist/editor.d.ts CHANGED
@@ -57,6 +57,64 @@ export declare function ToolbarButton({ label, pressed, onPress, className, chil
57
57
  className?: string;
58
58
  children: ReactNode;
59
59
  }): import("react").JSX.Element;
60
+ export interface WikiToolbarProps {
61
+ editor: Editor;
62
+ /** `useWikiEditor`'s `isActive`. */
63
+ isActive: (a?: ActiveDescriptor) => boolean;
64
+ /** `useUrlPrompt`'s state: the link button opens it, and shows pressed while it is open. */
65
+ prompt: UrlPromptState;
66
+ /** The formatting buttons, before Remove link, upload and embed. */
67
+ actions: readonly ToolbarAction[];
68
+ /** The insert buttons after them, such as table and tabs. */
69
+ trailing?: readonly ToolbarAction[];
70
+ /** Shows the upload button, which calls this: `useWikiEditor`'s `triggerUpload`. */
71
+ onUpload?: () => void;
72
+ /** Shows the embed button, which opens the prompt on an embed address. */
73
+ embed?: boolean;
74
+ icons: Partial<Record<ToolbarKey, ReactNode>> & {
75
+ unlink: ReactNode;
76
+ upload?: ReactNode;
77
+ embed?: ReactNode;
78
+ };
79
+ classNames?: {
80
+ toolbar?: string;
81
+ button?: string;
82
+ /** Added to a button that is on. */
83
+ pressed?: string;
84
+ divider?: string;
85
+ /** A table control, which is a word rather than an icon. */
86
+ tableButton?: string;
87
+ /** Added to the table controls that delete. */
88
+ danger?: string;
89
+ };
90
+ }
91
+ /**
92
+ * The formatting bar three wikis had each assembled from `toolbarActions`,
93
+ * `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
94
+ * a link button that needs an address and so opens the prompt, a Remove link
95
+ * button while the cursor is in a link, upload and embed between the format
96
+ * and insert groups, and the table controls while the cursor is in a table.
97
+ * Which buttons, which icons and which classes are the caller's.
98
+ */
99
+ export declare function WikiToolbar({ editor, isActive, prompt, actions, trailing, onUpload, embed, icons, classNames: c }: WikiToolbarProps): import("react").JSX.Element;
100
+ export interface UrlPromptFieldProps {
101
+ prompt: UrlPromptState;
102
+ placeholders?: {
103
+ link?: string;
104
+ embed?: string;
105
+ };
106
+ classNames?: {
107
+ row?: string;
108
+ input?: string;
109
+ apply?: string;
110
+ cancel?: string;
111
+ };
112
+ }
113
+ /**
114
+ * The address field under a toolbar, open while `prompt.kind` is set: Enter
115
+ * applies, Escape closes, and an emptied link address unlinks.
116
+ */
117
+ export declare function UrlPromptField({ prompt, placeholders, classNames: c }: UrlPromptFieldProps): import("react").JSX.Element | null;
60
118
  export type UrlPromptKind = 'link' | 'embed';
61
119
  export interface UrlPromptState {
62
120
  /** The open field, or null. */
package/dist/editor.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // editor.tsx — the wiki rich-text editor's engine, minus its chrome.
4
4
  //
5
5
  // `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
@@ -32,6 +32,7 @@ import Placeholder from '@tiptap/extension-placeholder';
32
32
  import { useEditor } from '@tiptap/react';
33
33
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
34
34
  import { toMapEmbedUrl } from './maps.js';
35
+ import { cx } from './html.js';
35
36
  // ---- paste scrubbing --------------------------------------------------------
36
37
  /**
37
38
  * Reduce pasted HTML to structure plus the three attributes that carry meaning.
@@ -219,6 +220,31 @@ export function toolbarActions(keys) {
219
220
  export function ToolbarButton({ label, pressed, onPress, className, children, }) {
220
221
  return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
221
222
  }
223
+ /**
224
+ * The formatting bar three wikis had each assembled from `toolbarActions`,
225
+ * `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
226
+ * a link button that needs an address and so opens the prompt, a Remove link
227
+ * button while the cursor is in a link, upload and embed between the format
228
+ * and insert groups, and the table controls while the cursor is in a table.
229
+ * Which buttons, which icons and which classes are the caller's.
230
+ */
231
+ export function WikiToolbar({ editor, isActive, prompt, actions, trailing = [], onUpload, embed, icons, classNames: c = {} }) {
232
+ const button = ({ key, label, active, run }) => {
233
+ const pressed = isActive(active) || (key === 'link' && prompt.kind === 'link');
234
+ return (_jsx(ToolbarButton, { label: label, pressed: active ? pressed : undefined, onPress: () => (run ? run(editor) : prompt.toggle('link')), className: cx(c.button, pressed && c.pressed), children: icons[key] }, key));
235
+ };
236
+ return (_jsxs("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: [actions.map(button), editor.isActive('link') && (_jsx(ToolbarButton, { label: "Remove link", onPress: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(), className: c.button, children: icons.unlink })), onUpload && (_jsx(ToolbarButton, { label: "Upload image", onPress: onUpload, className: c.button, children: icons.upload })), embed && (_jsx(ToolbarButton, { label: "Embed", pressed: prompt.kind === 'embed', onPress: () => prompt.toggle('embed'), className: cx(c.button, prompt.kind === 'embed' && c.pressed), children: icons.embed })), trailing.map(button), editor.isActive('table') && (_jsxs(_Fragment, { children: [_jsx("span", { className: c.divider, "aria-hidden": true }), TABLE_ACTIONS.map(([command, label, danger]) => (_jsx("button", { type: "button", className: cx(c.tableButton, danger && c.danger), onClick: () => runTableAction(editor, command), children: label }, command)))] }))] }));
237
+ }
238
+ /**
239
+ * The address field under a toolbar, open while `prompt.kind` is set: Enter
240
+ * applies, Escape closes, and an emptied link address unlinks.
241
+ */
242
+ export function UrlPromptField({ prompt, placeholders = {}, classNames: c = {} }) {
243
+ if (!prompt.kind)
244
+ return null;
245
+ const link = prompt.kind === 'link';
246
+ return (_jsxs("div", { className: c.row, children: [_jsx("input", { autoFocus: true, type: "text", inputMode: "url", autoCapitalize: "none", className: c.input, "aria-label": link ? 'Link address' : 'Embed address', placeholder: link ? (placeholders.link ?? 'https://… (empty removes the link)') : (placeholders.embed ?? 'YouTube, X, Google or Apple Maps, or any iframe URL'), value: prompt.value, onChange: e => prompt.setValue(e.target.value), onKeyDown: prompt.onKeyDown }), _jsx("button", { type: "button", className: c.apply, onClick: prompt.apply, children: link ? 'Apply' : 'Embed' }), _jsx("button", { type: "button", className: c.cancel, onClick: prompt.close, children: "Cancel" })] }));
247
+ }
222
248
  /**
223
249
  * What a link or embed field does with its address. A link sets or replaces
224
250
  * the link the cursor is in, across the whole link rather than the collapsed
package/dist/upload.d.ts CHANGED
@@ -1,27 +1,40 @@
1
1
  import { type Sharp } from 'sharp';
2
2
  import { type RateLimitOptions } from './rate-limit.js';
3
3
  export type ImageFormat = 'jpg' | 'png' | 'gif' | 'webp' | 'avif';
4
- export interface UploadOptions {
5
- /** The uploader's id, which keys the rate limit, or the response that turns them away. */
6
- authorize: (request: Request) => Promise<string | Response>;
7
- /** Default 4 MB. */
8
- maxBytes?: number;
4
+ type Encode = (image: Sharp, source: {
5
+ format: ImageFormat;
6
+ width: number;
7
+ height: number;
8
+ }) => {
9
+ image: Sharp;
10
+ format: ImageFormat;
11
+ };
12
+ export interface NormalizeOptions {
9
13
  /** Decoded pixels, default 40 million: a phone camera's full resolution. */
10
14
  maxPixels?: number;
11
- /** Default a burst of 20, then one every five seconds. */
12
- rateLimit?: RateLimitOptions;
13
15
  /**
14
16
  * How the upright image is written. Default the format it arrived in. A wiki
15
17
  * with an image standard resizes and converts here.
16
18
  */
17
- encode?: (image: Sharp, source: {
18
- format: ImageFormat;
19
- width: number;
20
- height: number;
21
- }) => {
22
- image: Sharp;
23
- format: ImageFormat;
24
- };
19
+ encode?: Encode;
20
+ }
21
+ /**
22
+ * The image re-encoded upright with no metadata, in the format `encode` chose.
23
+ * Throws, with a message fit to show the uploader, for bytes that are not an
24
+ * accepted image or decode to more than `maxPixels`.
25
+ */
26
+ export declare function normalizeImage(bytes: Buffer, { maxPixels, encode, }?: NormalizeOptions): Promise<{
27
+ buffer: Buffer;
28
+ format: ImageFormat;
29
+ contentType: string;
30
+ }>;
31
+ export interface UploadOptions extends NormalizeOptions {
32
+ /** The uploader's id, which keys the rate limit, or the response that turns them away. */
33
+ authorize: (request: Request) => Promise<string | Response>;
34
+ /** Default 4 MB. */
35
+ maxBytes?: number;
36
+ /** Default a burst of 20, then one every five seconds. */
37
+ rateLimit?: RateLimitOptions;
25
38
  /** Prepended to the stored name, e.g. `wiki/`. */
26
39
  prefix?: string;
27
40
  /** Stores the bytes and resolves to their public URL. Default Vercel Blob, public. */
@@ -29,3 +42,4 @@ export interface UploadOptions {
29
42
  }
30
43
  /** The POST handler: a multipart `file` in, `{ url }` out. */
31
44
  export declare function uploadHandler({ authorize, maxBytes, maxPixels, rateLimit, encode, prefix, store, }: UploadOptions): (request: Request) => Promise<Response>;
45
+ export {};
package/dist/upload.js CHANGED
@@ -15,6 +15,10 @@
15
15
  // EXIF; the orientation that metadata carried is applied first.
16
16
  // - **Each uploader has a budget**, keyed on who they are and where from.
17
17
  //
18
+ // `normalizeImage` is the sniff, the pixel cap and the re-encode without the
19
+ // route, for an app whose images arrive another way: a server action, or a
20
+ // picture copied in from another site.
21
+ //
18
22
  // `sharp` and `@vercel/blob` are optional peers. Run the route on Node, never
19
23
  // the edge: sharp is native.
20
24
  import sharp, {} from 'sharp';
@@ -43,11 +47,39 @@ function sniffImage(buf) {
43
47
  return 'avif';
44
48
  return null;
45
49
  }
50
+ /**
51
+ * The image re-encoded upright with no metadata, in the format `encode` chose.
52
+ * Throws, with a message fit to show the uploader, for bytes that are not an
53
+ * accepted image or decode to more than `maxPixels`.
54
+ */
55
+ export async function normalizeImage(bytes, { maxPixels = 40_000_000, encode = (image, { format }) => ({ image: image.toFormat(format === 'jpg' ? 'jpeg' : format), format }), } = {}) {
56
+ const megapixels = Math.round(maxPixels / 1_000_000);
57
+ const format = sniffImage(bytes);
58
+ if (!format)
59
+ throw new Error('Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF');
60
+ try {
61
+ const meta = await sharp(bytes, { limitInputPixels: maxPixels }).metadata();
62
+ const width = meta.width ?? 0;
63
+ const height = meta.pageHeight ?? meta.height ?? 0;
64
+ if (!width || !height)
65
+ throw new Error('no dimensions');
66
+ if (width * height > maxPixels)
67
+ throw new Error('pixel limit');
68
+ const upright = sharp(bytes, { animated: (meta.pages ?? 1) > 1, limitInputPixels: maxPixels }).rotate();
69
+ const encoded = encode(upright, { format, width, height });
70
+ return { buffer: await encoded.image.toBuffer(), format: encoded.format, contentType: CONTENT_TYPE[encoded.format] };
71
+ }
72
+ catch (e) {
73
+ // Never fall back to the bytes as sent: storing them is what leaks the location.
74
+ throw new Error(e instanceof Error && e.message.includes('pixel limit')
75
+ ? `Image too large (max ${megapixels} megapixels)`
76
+ : 'Could not read this image. Save it as PNG or JPEG and try again.');
77
+ }
78
+ }
46
79
  const vercelBlob = async (path, body, contentType) => (await put(path, body, { access: 'public', addRandomSuffix: false, contentType })).url;
47
80
  /** The POST handler: a multipart `file` in, `{ url }` out. */
48
- export function uploadHandler({ authorize, maxBytes = 4 * 1024 * 1024, maxPixels = 40_000_000, rateLimit = { capacity: 20, refillPerSec: 0.2 }, encode = (image, { format }) => ({ image: image.toFormat(format === 'jpg' ? 'jpeg' : format), format }), prefix = '', store = vercelBlob, }) {
81
+ export function uploadHandler({ authorize, maxBytes = 4 * 1024 * 1024, maxPixels, rateLimit = { capacity: 20, refillPerSec: 0.2 }, encode, prefix = '', store = vercelBlob, }) {
49
82
  const megabytes = Math.round((maxBytes / 1024 / 1024) * 10) / 10;
50
- const megapixels = Math.round(maxPixels / 1_000_000);
51
83
  return request => handleRoute(async () => {
52
84
  const who = await authorize(request);
53
85
  if (who instanceof Response)
@@ -61,31 +93,14 @@ export function uploadHandler({ authorize, maxBytes = 4 * 1024 * 1024, maxPixels
61
93
  return errors.badRequest('No file provided');
62
94
  if (file.size > maxBytes)
63
95
  return errors.badRequest(`File too large (max ${megabytes}MB)`);
64
- const bytes = Buffer.from(await file.arrayBuffer());
65
- const format = sniffImage(bytes);
66
- if (!format)
67
- return errors.badRequest('Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF');
68
96
  let out;
69
97
  try {
70
- const meta = await sharp(bytes, { limitInputPixels: maxPixels }).metadata();
71
- const width = meta.width ?? 0;
72
- const height = meta.pageHeight ?? meta.height ?? 0;
73
- if (!width || !height)
74
- throw new Error('no dimensions');
75
- if (width * height > maxPixels)
76
- return errors.badRequest(`Image too large (max ${megapixels} megapixels)`);
77
- const upright = sharp(bytes, { animated: (meta.pages ?? 1) > 1, limitInputPixels: maxPixels }).rotate();
78
- const encoded = encode(upright, { format, width, height });
79
- out = { buffer: await encoded.image.toBuffer(), format: encoded.format };
98
+ out = await normalizeImage(Buffer.from(await file.arrayBuffer()), { maxPixels, encode });
80
99
  }
81
100
  catch (e) {
82
- // Never fall back to the bytes as sent: storing them is what leaks the location.
83
- const tooBig = e instanceof Error && e.message.includes('pixel limit');
84
- return errors.badRequest(tooBig
85
- ? `Image too large (max ${megapixels} megapixels)`
86
- : 'Could not read this image. Save it as PNG or JPEG and try again.');
101
+ return errors.badRequest(e.message);
87
102
  }
88
- const url = await store(`${prefix}${crypto.randomUUID()}.${out.format}`, out.buffer, CONTENT_TYPE[out.format]);
103
+ const url = await store(`${prefix}${crypto.randomUUID()}.${out.format}`, out.buffer, out.contentType);
89
104
  return json({ url });
90
105
  }, 'Upload failed');
91
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -208,6 +208,11 @@
208
208
  "import": "./dist/editor.js",
209
209
  "default": "./dist/editor.js"
210
210
  },
211
+ "./block-editor": {
212
+ "types": "./dist/block-editor.d.ts",
213
+ "import": "./dist/block-editor.js",
214
+ "default": "./dist/block-editor.js"
215
+ },
211
216
  "./link-check": {
212
217
  "types": "./dist/link-check.d.ts",
213
218
  "import": "./dist/link-check.js",