mosage 0.2.0 → 0.8.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.
Files changed (121) hide show
  1. package/README.md +16 -15
  2. package/bin.js +2 -0
  3. package/dist/build-C7NW_3Pk.js +14 -0
  4. package/dist/check-CP4873Wx.js +41 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +228 -0
  7. package/dist/config-DPm1BBAb.js +2619 -0
  8. package/dist/config-TlTe7Ona.d.ts +24 -0
  9. package/dist/context-BqsdSrAQ.js +1084 -0
  10. package/dist/dev-Biz42qlu.js +17 -0
  11. package/dist/diagram-xlVDekYk.js +763 -0
  12. package/dist/export-Bi6nuxjT.js +31 -0
  13. package/dist/import-D2jNB07F.js +25 -0
  14. package/dist/index.d.ts +455 -0
  15. package/dist/index.js +693 -0
  16. package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
  17. package/dist/preview-CLm51aRt.js +19 -0
  18. package/dist/sdk-DjpX6mCv.js +51 -0
  19. package/dist/vite/index.d.ts +25 -0
  20. package/dist/vite/index.js +2 -0
  21. package/env.d.ts +83 -0
  22. package/package.json +59 -12
  23. package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
  24. package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
  25. package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
  26. package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
  27. package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
  28. package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
  29. package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
  30. package/src/app/app.tsx +42 -0
  31. package/src/app/components/data-table.tsx +196 -0
  32. package/src/app/components/design-panel/design-panel.tsx +318 -0
  33. package/src/app/components/design-panel/design-provider.tsx +121 -0
  34. package/src/app/components/design-panel/use-design.ts +85 -0
  35. package/src/app/components/diagram.tsx +76 -0
  36. package/src/app/components/doc-assets.tsx +129 -0
  37. package/src/app/components/doc-search.tsx +248 -0
  38. package/src/app/components/doc-sidebar.tsx +162 -0
  39. package/src/app/components/flow-page.tsx +93 -0
  40. package/src/app/components/footnote.tsx +204 -0
  41. package/src/app/components/image-placeholder.tsx +50 -0
  42. package/src/app/components/inspector/inspector.tsx +518 -0
  43. package/src/app/components/numbering.tsx +224 -0
  44. package/src/app/components/page-frame.tsx +70 -0
  45. package/src/app/components/sidebar/folder-item.tsx +212 -0
  46. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  47. package/src/app/components/sidebar/sidebar.tsx +252 -0
  48. package/src/app/components/table-of-contents.tsx +93 -0
  49. package/src/app/components/theme-toggle.tsx +50 -0
  50. package/src/app/components/themes/markdown.tsx +249 -0
  51. package/src/app/components/themes/theme-preview.tsx +74 -0
  52. package/src/app/components/ui/menu.tsx +143 -0
  53. package/src/app/index.html +12 -0
  54. package/src/app/lib/agent-bridge.ts +140 -0
  55. package/src/app/lib/assets.ts +151 -0
  56. package/src/app/lib/design-presets.ts +109 -0
  57. package/src/app/lib/design.ts +88 -0
  58. package/src/app/lib/diagnostics.ts +282 -0
  59. package/src/app/lib/doc-preview.tsx +29 -0
  60. package/src/app/lib/docs.ts +26 -0
  61. package/src/app/lib/docx/extract.ts +1623 -0
  62. package/src/app/lib/docx/fonts.test.ts +136 -0
  63. package/src/app/lib/docx/fonts.ts +166 -0
  64. package/src/app/lib/docx/media.ts +102 -0
  65. package/src/app/lib/docx/model.ts +206 -0
  66. package/src/app/lib/docx/paragraph.test.ts +92 -0
  67. package/src/app/lib/docx/paragraph.ts +107 -0
  68. package/src/app/lib/docx/props.ts +187 -0
  69. package/src/app/lib/docx/styles.ts +306 -0
  70. package/src/app/lib/docx/units.ts +35 -0
  71. package/src/app/lib/docx/write.test.ts +507 -0
  72. package/src/app/lib/docx/write.ts +581 -0
  73. package/src/app/lib/docx/xml.ts +39 -0
  74. package/src/app/lib/export-docx.ts +289 -0
  75. package/src/app/lib/export-dom.ts +318 -0
  76. package/src/app/lib/export-html.ts +156 -0
  77. package/src/app/lib/export-image.ts +70 -0
  78. package/src/app/lib/export-pdf.ts +165 -0
  79. package/src/app/lib/flow-measure.test.ts +31 -0
  80. package/src/app/lib/flow-measure.ts +183 -0
  81. package/src/app/lib/flow.test.ts +110 -0
  82. package/src/app/lib/flow.ts +136 -0
  83. package/src/app/lib/folders.ts +192 -0
  84. package/src/app/lib/footnotes.test.tsx +102 -0
  85. package/src/app/lib/footnotes.ts +94 -0
  86. package/src/app/lib/inspector/fiber.ts +99 -0
  87. package/src/app/lib/labels.test.ts +18 -0
  88. package/src/app/lib/labels.ts +181 -0
  89. package/src/app/lib/outline.ts +118 -0
  90. package/src/app/lib/page-context.tsx +43 -0
  91. package/src/app/lib/page-range.test.ts +95 -0
  92. package/src/app/lib/page-range.ts +90 -0
  93. package/src/app/lib/print-ready.ts +69 -0
  94. package/src/app/lib/rasterize.ts +173 -0
  95. package/src/app/lib/scan.ts +26 -0
  96. package/src/app/lib/sdk.test.ts +32 -0
  97. package/src/app/lib/sdk.ts +115 -0
  98. package/src/app/lib/themes.ts +31 -0
  99. package/src/app/lib/use-doc-module.ts +53 -0
  100. package/src/app/lib/use-doc-pages.ts +147 -0
  101. package/src/app/lib/utils.ts +6 -0
  102. package/src/app/lib/view-mode.test.ts +91 -0
  103. package/src/app/lib/view-mode.ts +104 -0
  104. package/src/app/main.tsx +14 -0
  105. package/src/app/routes/assets.tsx +257 -0
  106. package/src/app/routes/doc.tsx +877 -0
  107. package/src/app/routes/home-shell.tsx +203 -0
  108. package/src/app/routes/home.tsx +269 -0
  109. package/src/app/routes/themes.tsx +121 -0
  110. package/src/app/styles.css +97 -0
  111. package/src/app/virtual.d.ts +30 -0
  112. package/template/AGENTS.md +1 -1
  113. package/template/README.md +24 -52
  114. package/template/docs/getting-started/index.tsx +2 -2
  115. package/template/mosage.config.ts +1 -1
  116. package/template/package.json +1 -1
  117. package/template/tsconfig.json +1 -1
  118. package/README.zh-TW.md +0 -28
  119. /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
  120. /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
  121. /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
@@ -0,0 +1,85 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { DesignSystem } from '../../lib/design';
3
+
4
+ type FetchedState = {
5
+ design: DesignSystem | null;
6
+ exists: boolean;
7
+ warning: string | null;
8
+ loaded: boolean;
9
+ };
10
+
11
+ export type UseDesignReturn = FetchedState & {
12
+ refresh: () => Promise<void>;
13
+ save: (patch: Partial<DesignSystem>) => Promise<{ ok: boolean; error?: string }>;
14
+ reset: () => Promise<{ ok: boolean; error?: string }>;
15
+ };
16
+
17
+ const INITIAL: FetchedState = { design: null, exists: false, warning: null, loaded: false };
18
+
19
+ export function useDesign(docId: string): UseDesignReturn {
20
+ const [state, setState] = useState<FetchedState>(INITIAL);
21
+ const docIdRef = useRef(docId);
22
+ docIdRef.current = docId;
23
+
24
+ const refresh = useCallback(async () => {
25
+ const id = docIdRef.current;
26
+ if (!id) return;
27
+ try {
28
+ const res = await fetch(`/__design?docId=${encodeURIComponent(id)}`);
29
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
30
+ const body = (await res.json()) as {
31
+ design: DesignSystem;
32
+ exists: boolean;
33
+ warning: string | null;
34
+ };
35
+ setState({ design: body.design, exists: body.exists, warning: body.warning, loaded: true });
36
+ } catch (err) {
37
+ setState((s) => ({ ...s, warning: String((err as Error).message), loaded: true }));
38
+ }
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ setState(INITIAL);
43
+ void refresh();
44
+ }, [refresh]);
45
+
46
+ const save = useCallback(async (patch: Partial<DesignSystem>) => {
47
+ const id = docIdRef.current;
48
+ if (!id) return { ok: false, error: 'no document id' };
49
+ try {
50
+ const res = await fetch(`/__design?docId=${encodeURIComponent(id)}`, {
51
+ method: 'PUT',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ patch }),
54
+ });
55
+ const body = (await res.json()) as { ok?: boolean; error?: string; design?: DesignSystem };
56
+ if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` };
57
+ if (body.design) {
58
+ setState((s) => ({ ...s, design: body.design ?? s.design, exists: true, warning: null }));
59
+ }
60
+ return { ok: true };
61
+ } catch (err) {
62
+ return { ok: false, error: String((err as Error).message) };
63
+ }
64
+ }, []);
65
+
66
+ const reset = useCallback(async () => {
67
+ const id = docIdRef.current;
68
+ if (!id) return { ok: false, error: 'no document id' };
69
+ try {
70
+ const res = await fetch(`/__design/reset?docId=${encodeURIComponent(id)}`, {
71
+ method: 'POST',
72
+ });
73
+ const body = (await res.json()) as { ok?: boolean; error?: string; design?: DesignSystem };
74
+ if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` };
75
+ if (body.design) {
76
+ setState((s) => ({ ...s, design: body.design ?? s.design, exists: true, warning: null }));
77
+ }
78
+ return { ok: true };
79
+ } catch (err) {
80
+ return { ok: false, error: String((err as Error).message) };
81
+ }
82
+ }, []);
83
+
84
+ return { ...state, refresh, save, reset };
85
+ }
@@ -0,0 +1,76 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+ import type { LabelKind } from '../lib/labels';
3
+ import { Figure } from './numbering';
4
+
5
+ export type DiagramSource = {
6
+ svg: string;
7
+ width: number;
8
+ height: number;
9
+ };
10
+
11
+ export type DiagramProps = {
12
+ /** The compiled module from `import chart from './architecture.mmd'`. */
13
+ chart: DiagramSource;
14
+ /** Caption text. Given one, the drawing is numbered like any other figure. */
15
+ caption?: ReactNode;
16
+ captionText?: string;
17
+ kind?: LabelKind;
18
+ id?: string;
19
+ /**
20
+ * Drawn width in CSS px. Defaults to the diagram's natural size, capped to
21
+ * the column — a diagram wider than the text block is a layout fault, and
22
+ * `mosage check` would report it as one.
23
+ */
24
+ width?: number;
25
+ align?: 'left' | 'center';
26
+ style?: CSSProperties;
27
+ className?: string;
28
+ };
29
+
30
+ /**
31
+ * A diagram compiled from Mermaid-flavoured text at build time. The SVG is
32
+ * inlined rather than referenced through `<img>` so it inherits the document's
33
+ * theme variables — an `<img src="data:…">` would be painted in a document of
34
+ * its own, with none of this one's colours or faces.
35
+ */
36
+ export function Diagram({
37
+ chart,
38
+ caption,
39
+ captionText,
40
+ kind = 'figure',
41
+ id,
42
+ width,
43
+ align = 'center',
44
+ style,
45
+ className,
46
+ }: DiagramProps) {
47
+ const drawing = (
48
+ <div
49
+ className={caption ? undefined : className}
50
+ style={{
51
+ width: width ?? chart.width,
52
+ maxWidth: '100%',
53
+ marginLeft: align === 'center' ? 'auto' : undefined,
54
+ marginRight: align === 'center' ? 'auto' : undefined,
55
+ ...(caption ? undefined : style),
56
+ }}
57
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: the SVG is produced by this package's own renderer at build time, never from document input at runtime
58
+ dangerouslySetInnerHTML={{ __html: chart.svg }}
59
+ />
60
+ );
61
+
62
+ if (!caption) return drawing;
63
+
64
+ return (
65
+ <Figure
66
+ {...(id ? { id } : {})}
67
+ caption={caption}
68
+ {...(captionText ? { captionText } : {})}
69
+ kind={kind}
70
+ className={className}
71
+ {...(style ? { style } : {})}
72
+ >
73
+ {drawing}
74
+ </Figure>
75
+ );
76
+ }
@@ -0,0 +1,129 @@
1
+ import { Check, Copy, FileIcon, Loader2 } from 'lucide-react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import {
4
+ type Asset,
5
+ formatBytes,
6
+ GLOBAL_SCOPE,
7
+ importSnippet,
8
+ isPreviewable,
9
+ listAssets,
10
+ } from '../lib/assets';
11
+ import { cn } from '../lib/utils';
12
+
13
+ type Group = { scope: string; label: string; assets: Asset[] };
14
+
15
+ /**
16
+ * The images this document can reach, without leaving it: its own `assets/`
17
+ * folder plus the shared project folder. Clicking one gives the import line to
18
+ * paste into the source.
19
+ */
20
+ export function DocAssets({ docId }: { docId: string }) {
21
+ const [groups, setGroups] = useState<Group[] | null>(null);
22
+ const [selected, setSelected] = useState<Asset | null>(null);
23
+ const [copied, setCopied] = useState(false);
24
+
25
+ const load = useCallback(async () => {
26
+ const [own, global] = await Promise.all([listAssets(docId), listAssets(GLOBAL_SCOPE)]);
27
+ setGroups([
28
+ { scope: docId, label: 'This document', assets: own.ok ? own.value : [] },
29
+ { scope: GLOBAL_SCOPE, label: 'Project', assets: global.ok ? global.value : [] },
30
+ ]);
31
+ }, [docId]);
32
+
33
+ useEffect(() => {
34
+ setGroups(null);
35
+ void load();
36
+ if (!import.meta.hot) return;
37
+ const handler = () => void load();
38
+ import.meta.hot.on('mosage:files-changed', handler);
39
+ return () => {
40
+ import.meta.hot?.off('mosage:files-changed', handler);
41
+ };
42
+ }, [load]);
43
+
44
+ const copyImport = async (asset: Asset) => {
45
+ try {
46
+ await navigator.clipboard.writeText(importSnippet(asset));
47
+ setCopied(true);
48
+ setTimeout(() => setCopied(false), 1200);
49
+ } catch {}
50
+ };
51
+
52
+ if (groups === null) {
53
+ return (
54
+ <div className="grid flex-1 place-items-center">
55
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ const empty = groups.every((group) => group.assets.length === 0);
61
+
62
+ return (
63
+ <div className="flex-1 overflow-y-auto px-3 pb-6">
64
+ {empty && (
65
+ <p className="px-1 py-2 text-muted-foreground text-xs leading-relaxed">
66
+ No assets yet. Drop files into <code className="font-mono">docs/{docId}/assets/</code> or
67
+ upload them from the Assets page.
68
+ </p>
69
+ )}
70
+
71
+ {groups.map((group) =>
72
+ group.assets.length === 0 ? null : (
73
+ <section key={group.scope} className="mb-4">
74
+ <h3 className="mb-1.5 px-1 text-[10px] text-muted-foreground uppercase tracking-wider">
75
+ {group.label}
76
+ </h3>
77
+ <div className="grid grid-cols-2 gap-1.5">
78
+ {group.assets.map((asset) => (
79
+ <button
80
+ key={`${group.scope}/${asset.name}`}
81
+ type="button"
82
+ title={asset.name}
83
+ onClick={() => setSelected(asset)}
84
+ className={cn(
85
+ 'grid h-16 place-items-center overflow-hidden rounded border bg-muted p-1 transition-colors',
86
+ selected?.url === asset.url
87
+ ? 'border-foreground'
88
+ : 'border-border hover:border-foreground/40',
89
+ )}
90
+ >
91
+ {isPreviewable(asset.mime) ? (
92
+ <img
93
+ src={asset.url}
94
+ alt={asset.name}
95
+ className="max-h-full max-w-full object-contain"
96
+ />
97
+ ) : (
98
+ <FileIcon className="size-4 text-muted-foreground" />
99
+ )}
100
+ </button>
101
+ ))}
102
+ </div>
103
+ </section>
104
+ ),
105
+ )}
106
+
107
+ {selected && (
108
+ <div className="sticky bottom-0 rounded-md border border-border bg-background p-2">
109
+ <p className="truncate font-medium text-[11px]">{selected.name}</p>
110
+ <p className="mt-0.5 text-[10px] text-muted-foreground">
111
+ {formatBytes(selected.size)}
112
+ {selected.unused ? ' · unused' : ''}
113
+ </p>
114
+ <code className="mt-1.5 block truncate rounded bg-muted px-1.5 py-1 font-mono text-[10px]">
115
+ {selected.importPath}
116
+ </code>
117
+ <button
118
+ type="button"
119
+ onClick={() => copyImport(selected)}
120
+ className="mt-1.5 flex w-full items-center justify-center gap-1 rounded border border-border px-2 py-1 text-[11px] transition-colors hover:bg-accent"
121
+ >
122
+ {copied ? <Check className="size-3" /> : <Copy className="size-3" />}
123
+ {copied ? 'Copied' : 'Copy import'}
124
+ </button>
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Find text in the document.
3
+ *
4
+ * Matches are drawn with the CSS Custom Highlight API, which paints ranges held
5
+ * beside the DOM instead of wrapping anything in it. Wrapping would mean editing
6
+ * a tree React owns: the next render throws the marks away, or keeps them and
7
+ * loses the reader's place. Neither is recoverable from inside a highlighter.
8
+ *
9
+ * A browser without the API still gets working navigation — the view scrolls to
10
+ * each hit — it just cannot tint it. That is worth having; refusing to search at
11
+ * all because it cannot be coloured is not.
12
+ */
13
+
14
+ import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+
17
+ const ALL = 'od-search';
18
+ const ACTIVE = 'od-search-active';
19
+
20
+ type Hit = { range: Range; page: number };
21
+
22
+ /* Case-insensitive, and accent-insensitive where the browser can be: a reader
23
+ searching for "resume" means the word, not the spelling with accents. Defined
24
+ outside the component so it is one function, not a new one every render —
25
+ which is also what stops it having to appear in a dependency list. */
26
+ const fold = (value: string): string => value.normalize('NFKD').toLowerCase();
27
+
28
+ const highlightsSupported = (): boolean =>
29
+ typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined';
30
+
31
+ function clearHighlights(): void {
32
+ if (!highlightsSupported()) return;
33
+ CSS.highlights.delete(ALL);
34
+ CSS.highlights.delete(ACTIVE);
35
+ }
36
+
37
+ /**
38
+ * Every text node under `root`, in document order.
39
+ *
40
+ * Script and style hold text that is not the document's text; matching inside
41
+ * them would send the reader to a hit they cannot see.
42
+ */
43
+ function textNodes(root: HTMLElement): Text[] {
44
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
45
+ acceptNode(node) {
46
+ const parent = node.parentElement;
47
+ if (!parent) return NodeFilter.FILTER_REJECT;
48
+ if (parent.closest('script,style,[data-od-search-skip]')) return NodeFilter.FILTER_REJECT;
49
+ return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
50
+ },
51
+ });
52
+ const out: Text[] = [];
53
+ let node = walker.nextNode();
54
+ while (node) {
55
+ out.push(node as Text);
56
+ node = walker.nextNode();
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function pageOf(node: Node, frames: Element[]): number {
62
+ const element = node.parentElement;
63
+ if (!element) return 0;
64
+ for (let i = 0; i < frames.length; i++) {
65
+ if (frames[i]?.contains(element)) return i + 1;
66
+ }
67
+ return 0;
68
+ }
69
+
70
+ export function DocSearch({
71
+ scrollRef,
72
+ pagesRef,
73
+ onFoundPage,
74
+ }: {
75
+ scrollRef: React.RefObject<HTMLElement | null>;
76
+ pagesRef: React.RefObject<HTMLElement | null>;
77
+ onFoundPage?: (page: number) => void;
78
+ }) {
79
+ const [open, setOpen] = useState(false);
80
+ const [query, setQuery] = useState('');
81
+ const [hits, setHits] = useState<Hit[]>([]);
82
+ const [at, setAt] = useState(0);
83
+ const inputRef = useRef<HTMLInputElement>(null);
84
+
85
+ const supported = useMemo(highlightsSupported, []);
86
+
87
+ const search = useCallback(
88
+ (text: string) => {
89
+ const root = pagesRef.current;
90
+ if (!root || text.trim() === '') {
91
+ clearHighlights();
92
+ setHits([]);
93
+ setAt(0);
94
+ return;
95
+ }
96
+
97
+ const needle = fold(text);
98
+ const frames = Array.from(root.children);
99
+ const found: Hit[] = [];
100
+
101
+ for (const node of textNodes(root)) {
102
+ const haystack = fold(node.nodeValue ?? '');
103
+ let from = haystack.indexOf(needle);
104
+ while (from !== -1) {
105
+ const range = document.createRange();
106
+ try {
107
+ range.setStart(node, from);
108
+ range.setEnd(node, from + needle.length);
109
+ found.push({ range, page: pageOf(node, frames) });
110
+ } catch {
111
+ /* Folding can change length, which puts the offset past the node.
112
+ Skipping that hit is better than throwing away the whole search. */
113
+ }
114
+ from = haystack.indexOf(needle, from + needle.length);
115
+ }
116
+ }
117
+
118
+ setHits(found);
119
+ setAt(found.length > 0 ? 1 : 0);
120
+ },
121
+ [pagesRef],
122
+ );
123
+
124
+ /* Painting is a side effect of the hits and the cursor, not of typing —
125
+ otherwise the active hit stays tinted after the reader moves off it. */
126
+ useEffect(() => {
127
+ if (!supported) return;
128
+ if (hits.length === 0) {
129
+ clearHighlights();
130
+ return;
131
+ }
132
+ const active = hits[at - 1];
133
+ CSS.highlights.set(ALL, new Highlight(...hits.map((hit) => hit.range)));
134
+ if (active) CSS.highlights.set(ACTIVE, new Highlight(active.range));
135
+ return clearHighlights;
136
+ }, [hits, at, supported]);
137
+
138
+ const go = useCallback(
139
+ (step: number) => {
140
+ if (hits.length === 0) return;
141
+ const next = ((at - 1 + step + hits.length) % hits.length) + 1;
142
+ setAt(next);
143
+ const hit = hits[next - 1];
144
+ if (!hit) return;
145
+ const target = hit.range.startContainer.parentElement;
146
+ const root = scrollRef.current;
147
+ if (target && root) {
148
+ const offset = target.getBoundingClientRect().top - root.getBoundingClientRect().top;
149
+ root.scrollTo({ top: root.scrollTop + offset - 120, behavior: 'smooth' });
150
+ }
151
+ if (hit.page > 0) onFoundPage?.(hit.page);
152
+ },
153
+ [at, hits, scrollRef, onFoundPage],
154
+ );
155
+
156
+ const close = useCallback(() => {
157
+ setOpen(false);
158
+ setQuery('');
159
+ clearHighlights();
160
+ setHits([]);
161
+ setAt(0);
162
+ }, []);
163
+
164
+ /* No ⌘F: that is the browser's own find, and taking it would replace a
165
+ control the reader already trusts with one that only searches this pane. */
166
+ useEffect(() => {
167
+ if (!open) return;
168
+ const onKey = (event: KeyboardEvent) => {
169
+ if (event.key === 'Escape') close();
170
+ };
171
+ window.addEventListener('keydown', onKey);
172
+ return () => window.removeEventListener('keydown', onKey);
173
+ }, [open, close]);
174
+
175
+ useEffect(() => () => clearHighlights(), []);
176
+
177
+ if (!open) {
178
+ return (
179
+ <button
180
+ type="button"
181
+ aria-label="Find in document"
182
+ title="Find in document"
183
+ onClick={() => {
184
+ setOpen(true);
185
+ requestAnimationFrame(() => inputRef.current?.focus());
186
+ }}
187
+ className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
188
+ >
189
+ <Search className="size-3.5" />
190
+ </button>
191
+ );
192
+ }
193
+
194
+ return (
195
+ <div className="flex items-center gap-1 rounded-md border border-border px-1.5 py-0.5 focus-within:border-foreground/40">
196
+ <Search className="size-3.5 flex-none text-muted-foreground" />
197
+ <input
198
+ ref={inputRef}
199
+ value={query}
200
+ onChange={(event) => {
201
+ setQuery(event.target.value);
202
+ search(event.target.value);
203
+ }}
204
+ onKeyDown={(event) => {
205
+ if (event.key === 'Enter') {
206
+ event.preventDefault();
207
+ go(event.shiftKey ? -1 : 1);
208
+ }
209
+ }}
210
+ placeholder="Find"
211
+ aria-label="Find in document"
212
+ className="w-28 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
213
+ />
214
+ <span className="flex-none font-mono text-[11px] text-muted-foreground tabular-nums">
215
+ {query === '' ? '' : hits.length === 0 ? '0' : `${at}/${hits.length}`}
216
+ </span>
217
+ <button
218
+ type="button"
219
+ aria-label="Previous match"
220
+ title="Previous match (⇧⏎)"
221
+ onClick={() => go(-1)}
222
+ disabled={hits.length === 0}
223
+ className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
224
+ >
225
+ <ChevronUp className="size-3.5" />
226
+ </button>
227
+ <button
228
+ type="button"
229
+ aria-label="Next match"
230
+ title="Next match (⏎)"
231
+ onClick={() => go(1)}
232
+ disabled={hits.length === 0}
233
+ className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
234
+ >
235
+ <ChevronDown className="size-3.5" />
236
+ </button>
237
+ <button
238
+ type="button"
239
+ aria-label="Close search"
240
+ title="Close search (Esc)"
241
+ onClick={close}
242
+ className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
243
+ >
244
+ <X className="size-3.5" />
245
+ </button>
246
+ </div>
247
+ );
248
+ }
@@ -0,0 +1,162 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { DesignSystem } from '../lib/design';
3
+ import type { OutlineEntry } from '../lib/outline';
4
+ import type { PageGeometry } from '../lib/sdk';
5
+ import type { ExpandedPage } from '../lib/use-doc-pages';
6
+ import { cn } from '../lib/utils';
7
+ import { DocAssets } from './doc-assets';
8
+ import { PageFrame } from './page-frame';
9
+
10
+ const THUMB_WIDTH = 116;
11
+
12
+ type Tab = 'pages' | 'outline' | 'assets';
13
+
14
+ type Props = {
15
+ docId: string;
16
+ pages: ExpandedPage[];
17
+ geometry: PageGeometry;
18
+ design?: DesignSystem;
19
+ currentPage: number;
20
+ entries: OutlineEntry[];
21
+ activeId: string | null;
22
+ onSelectPage: (page: number) => void;
23
+ onSelectEntry: (entry: OutlineEntry) => void;
24
+ };
25
+
26
+ export function DocSidebar({
27
+ docId,
28
+ pages,
29
+ geometry,
30
+ design,
31
+ currentPage,
32
+ entries,
33
+ activeId,
34
+ onSelectPage,
35
+ onSelectEntry,
36
+ }: Props) {
37
+ const [tab, setTab] = useState<Tab>('pages');
38
+ const railRef = useRef<HTMLDivElement>(null);
39
+ // The assets panel talks to the dev API, so it has nothing to show in a
40
+ // static build.
41
+ const tabs = useMemo<Tab[]>(
42
+ () => (import.meta.env.DEV ? ['pages', 'outline', 'assets'] : ['pages', 'outline']),
43
+ [],
44
+ );
45
+
46
+ // Follow the reader: keep the active thumbnail in view as the main pane scrolls.
47
+ useEffect(() => {
48
+ if (tab !== 'pages') return;
49
+ const rail = railRef.current;
50
+ const active = rail?.querySelector<HTMLElement>(`[data-thumb-page="${currentPage}"]`);
51
+ if (!rail || !active) return;
52
+ const top = active.offsetTop;
53
+ const bottom = top + active.offsetHeight;
54
+ if (top < rail.scrollTop || bottom > rail.scrollTop + rail.clientHeight) {
55
+ rail.scrollTo({ top: top - rail.clientHeight / 3, behavior: 'smooth' });
56
+ }
57
+ }, [currentPage, tab]);
58
+
59
+ return (
60
+ <aside className="hidden w-56 flex-none flex-col border-border border-r bg-background md:flex">
61
+ <div className="flex flex-none gap-1 p-2">
62
+ {tabs.map((value) => (
63
+ <button
64
+ key={value}
65
+ type="button"
66
+ onClick={() => setTab(value)}
67
+ className={cn(
68
+ 'flex-1 rounded-md px-2 py-1 text-[11px] capitalize transition-colors',
69
+ tab === value
70
+ ? 'bg-accent font-medium text-foreground'
71
+ : 'text-muted-foreground hover:bg-accent/60',
72
+ )}
73
+ >
74
+ {value}
75
+ </button>
76
+ ))}
77
+ </div>
78
+
79
+ {tab === 'assets' ? (
80
+ <DocAssets docId={docId} />
81
+ ) : tab === 'pages' ? (
82
+ <div ref={railRef} className="flex-1 overflow-y-auto px-3 pb-6">
83
+ <div className="flex flex-col items-center gap-3">
84
+ {pages.map((entry, index) => {
85
+ const page = index + 1;
86
+ return (
87
+ <button
88
+ key={entry.key}
89
+ type="button"
90
+ data-thumb-page={page}
91
+ onClick={() => onSelectPage(page)}
92
+ className="flex flex-col items-center gap-1"
93
+ >
94
+ <div
95
+ className={cn(
96
+ 'overflow-hidden rounded-sm ring-1 transition-shadow',
97
+ page === currentPage
98
+ ? 'ring-2 ring-foreground'
99
+ : 'ring-border hover:ring-foreground/40',
100
+ )}
101
+ style={{
102
+ width: THUMB_WIDTH,
103
+ height: geometry.height * (THUMB_WIDTH / geometry.width),
104
+ }}
105
+ >
106
+ <PageFrame
107
+ index={index}
108
+ total={pages.length}
109
+ geometry={geometry}
110
+ scale={THUMB_WIDTH / geometry.width}
111
+ design={design}
112
+ flat
113
+ >
114
+ {entry.content}
115
+ </PageFrame>
116
+ </div>
117
+ <span
118
+ className={cn(
119
+ 'font-mono text-[10px] tabular-nums',
120
+ page === currentPage ? 'text-foreground' : 'text-muted-foreground',
121
+ )}
122
+ >
123
+ {page}
124
+ </span>
125
+ </button>
126
+ );
127
+ })}
128
+ </div>
129
+ </div>
130
+ ) : (
131
+ <nav className="flex-1 overflow-y-auto px-2 pb-6">
132
+ {entries.length === 0 ? (
133
+ <p className="px-2 py-1 text-muted-foreground text-xs leading-relaxed">
134
+ No headings yet. Add an <code className="font-mono">h1</code>/
135
+ <code className="font-mono">h2</code> to a page and it shows up here.
136
+ </p>
137
+ ) : (
138
+ entries.map((entry) => (
139
+ <button
140
+ key={entry.id}
141
+ type="button"
142
+ onClick={() => onSelectEntry(entry)}
143
+ className={cn(
144
+ 'flex w-full items-baseline gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-accent',
145
+ entry.id === activeId
146
+ ? 'bg-accent font-medium text-foreground'
147
+ : 'text-muted-foreground',
148
+ )}
149
+ style={{ paddingLeft: 8 + (entry.level - 1) * 12 }}
150
+ >
151
+ <span className="line-clamp-2 flex-1">{entry.text}</span>
152
+ <span className="font-mono text-[10px] text-muted-foreground tabular-nums">
153
+ {entry.page}
154
+ </span>
155
+ </button>
156
+ ))
157
+ )}
158
+ </nav>
159
+ )}
160
+ </aside>
161
+ );
162
+ }