wiki-formant 0.50.0 → 0.52.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.
@@ -16,21 +16,56 @@
16
16
  // npx prisma-models --check # exit 1 if either is missing or differs
17
17
  // npx prisma-models analytics # only the ones named
18
18
  // npx prisma-models --dir db/schema # another folder
19
+ //
20
+ // An app that keeps one schema file and hand-writes the models into it, with a
21
+ // relation the shipped ones cannot carry, checks them in place instead.
22
+ // `--tenant` lets each model's `siteId` drop its default and gain a relation:
23
+ //
24
+ // npx prisma-models analytics --check --in prisma/schema.prisma --tenant
19
25
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
20
26
  import { join } from 'node:path';
27
+ import { arg } from './_cli.mjs';
21
28
 
22
29
  const MODELS = ['analytics', 'passkey'];
23
30
  const args = process.argv.slice(2);
24
31
  const check = args.includes('--check');
25
- const at = args.indexOf('--dir');
26
- const dir = at >= 0 ? args[at + 1] : 'prisma/schema';
27
- const named = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--dir');
32
+ const dir = arg('--dir') ?? 'prisma/schema';
33
+ const into = arg('--in');
34
+ const tenant = args.includes('--tenant');
35
+ const named = args.filter((a, i) => !a.startsWith('--') && !['--dir', '--in'].includes(args[i - 1]));
28
36
  const unknown = named.filter(n => !MODELS.includes(n));
29
37
 
30
- if (unknown.length || !dir) {
31
- console.error(`usage: prisma-models [${MODELS.join('|')}…] [--dir <folder>] [--check]`);
38
+ if (unknown.length || !dir || (into !== undefined && !check)) {
39
+ console.error(`usage: prisma-models [${MODELS.join('|')}…] [--dir <folder>] [--check [--in <schema file> [--tenant]]]`);
32
40
  process.exit(2);
33
41
  }
42
+
43
+ // A model as `name → its lines`, comments, blank lines and spacing dropped.
44
+ const models = text =>
45
+ new Map(
46
+ [...text.matchAll(/^model\s+(\w+)\s*\{[ \t]*$([\s\S]*?)^\}/gm)].map(([, name, body]) => [
47
+ name,
48
+ body
49
+ .split('\n')
50
+ .map(l => l.replace(/\/\/.*/, '').trim().replace(/\s+/g, ' '))
51
+ .filter(l => l && !(tenant && /^\w+ \w+ @relation\(fields: \[siteId\]/.test(l)))
52
+ .map(l => (tenant ? l.replace(/^siteId String @default\(""\)$/, 'siteId String') : l))
53
+ .join('\n'),
54
+ ]),
55
+ );
56
+
57
+ if (into !== undefined) {
58
+ const local = models(readFileSync(into, 'utf8'));
59
+ let drift = 0;
60
+ for (const name of named.length ? named : MODELS) {
61
+ for (const [model, body] of models(readFileSync(new URL(`../prisma/${name}.prisma`, import.meta.url), 'utf8'))) {
62
+ if (local.get(model) === body) continue;
63
+ drift++;
64
+ console.error(`${into}: model ${model} ${local.has(model) ? 'differs from' : 'is missing from'} wiki-formant's prisma/${name}.prisma`);
65
+ }
66
+ }
67
+ process.exit(drift ? 1 : 0);
68
+ }
34
69
  if (!existsSync(dir)) {
35
70
  console.error(`prisma-models: no ${dir}/ here. Move prisma/schema.prisma into it and point prisma.config.ts's schema at the folder.`);
36
71
  process.exit(2);
package/dist/base.css CHANGED
@@ -40,6 +40,31 @@
40
40
  .columns-layout > .column-view { align-self: stretch; }
41
41
  }
42
42
 
43
+ /* ---- layout primitives ------------------------------------------------------
44
+ A column, a row and a row with its ends pushed apart. radix-wiki and caper
45
+ wrote these with the same gaps, and acuiq2 the column; a site that meant
46
+ something else by a name renamed its own. The gaps are the ones all of them
47
+ used, so they stay here rather than going back to every stylesheet. */
48
+ .stack, .stack-xs, .stack-sm, .stack-lg { display: flex; flex-direction: column; gap: 1rem; }
49
+ .stack-xs { gap: 0.25rem; }
50
+ .stack-sm { gap: 0.5rem; }
51
+ .stack-lg { gap: 1.5rem; }
52
+ .row, .row-xs, .row-md, .row-lg { display: flex; align-items: center; gap: 0.5rem; }
53
+ .row-xs { gap: 0.25rem; }
54
+ .row-md { gap: 0.75rem; }
55
+ .row-lg { gap: 1rem; }
56
+ .spread { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; }
57
+ .badge { display: inline-flex; align-items: center; border-radius: 9999px; }
58
+
59
+ /* A page whose first block is an infobox, one column with the infobox on top.
60
+ Where it splits into prose and a rail is the site's: a viewport width on one,
61
+ its column's width on another. Both items need `min-width: 0`, or the one
62
+ shared track sizes to the widest item's min-content and the other inherits
63
+ the overflow. */
64
+ .page-with-infobox { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "infobox" "content"; }
65
+ .page-with-infobox > .page-main-content { grid-area: content; min-width: 0; }
66
+ .page-with-infobox > .infobox { grid-area: infobox; min-width: 0; }
67
+
43
68
  /* ---- the block views and the infobox ---------------------------------------
44
69
  The structure three or four sites had each restated around this markup;
45
70
  padding, gaps, radii and colour stay theirs. A stats block is two columns,
@@ -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
+ }
@@ -2,9 +2,8 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // block-views.tsx — the block leaves the wikis share.
4
4
  //
5
- // Behind its own subpath for the reason `react.tsx` is: React is an OPTIONAL
6
- // peer, so a consumer that only wants the taxonomy or the MCP transport still
7
- // installs a package with no runtime dependencies.
5
+ // Behind its own subpath for the reason `react.tsx` is: React is an optional
6
+ // peer, so a consumer that only wants the taxonomy or the MCP transport needs no React.
8
7
  //
9
8
  // WHY THESE FIVE AND NOT THE SWITCH. The block union is per-repo and staying
10
9
  // that way (see the Block Model note in the workspace CLAUDE.md) — a closed
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;
@@ -14,8 +14,8 @@ export interface AiCrawler {
14
14
  }
15
15
  /**
16
16
  * Structurally what Next's `MetadataRoute.Robots['rules']` wants, without
17
- * importing Next: this package has no runtime dependencies and is not going to
18
- * grow one for a shape that is three optional string fields.
17
+ * importing Next: this package does not depend on Next and is not going to for
18
+ * a shape that is three optional string fields.
19
19
  */
20
20
  export interface RobotsGroup {
21
21
  userAgent: string;
package/dist/db.d.ts CHANGED
@@ -19,3 +19,28 @@ export interface PgPoolConfig {
19
19
  * a page that fans out more than three queries in thirty seconds.
20
20
  */
21
21
  export declare function pgPoolConfig(url: string | undefined): PgPoolConfig;
22
+ /**
23
+ * What every app's prisma.config.ts hands `defineConfig`, from the folder the
24
+ * file sits in: `defineConfig(prismaConfig(import.meta.dirname))`. Four apps
25
+ * carried the file, three of them byte for byte.
26
+ *
27
+ * Read only by the Prisma CLI, never by the app, so the URL prefers DIRECT_URL:
28
+ * every CLI operation that connects (db push, migrate diff, studio) needs a
29
+ * session connection, and Supabase's transaction pooler on 6543, which
30
+ * DATABASE_URL names, hangs on them rather than failing. A placeholder stands
31
+ * in where neither is set, as on a Vercel build: `prisma generate` reads the
32
+ * schema and never connects, and anything that does connect fails loudly.
33
+ *
34
+ * `schema` is a folder of .prisma files (`prisma-models` writes beside the
35
+ * app's own) or a single file. Paths resolve against `root`, so the CLI works
36
+ * from a subdirectory.
37
+ */
38
+ export declare function prismaConfig(root: string, schema?: string): {
39
+ schema: string;
40
+ migrations: {
41
+ path: string;
42
+ };
43
+ datasource: {
44
+ url: string;
45
+ };
46
+ };