mosage 0.1.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 (146) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +16 -217
  3. package/bin.js +2 -0
  4. package/dist/build-C7NW_3Pk.js +14 -0
  5. package/dist/check-CP4873Wx.js +41 -0
  6. package/dist/cli/bin.d.ts +1 -0
  7. package/dist/cli/bin.js +228 -0
  8. package/dist/config-DPm1BBAb.js +2619 -0
  9. package/dist/config-TlTe7Ona.d.ts +24 -0
  10. package/dist/context-BqsdSrAQ.js +1084 -0
  11. package/dist/dev-Biz42qlu.js +17 -0
  12. package/dist/diagram-xlVDekYk.js +763 -0
  13. package/dist/export-Bi6nuxjT.js +31 -0
  14. package/dist/import-D2jNB07F.js +25 -0
  15. package/dist/index.d.ts +455 -0
  16. package/dist/index.js +693 -0
  17. package/dist/init-Bbtj2pxF.js +262 -0
  18. package/dist/preview-CLm51aRt.js +19 -0
  19. package/dist/sdk-DjpX6mCv.js +51 -0
  20. package/dist/vite/index.d.ts +25 -0
  21. package/dist/vite/index.js +2 -0
  22. package/env.d.ts +83 -0
  23. package/package.json +84 -64
  24. package/skills/apply-comments/SKILL.md +43 -56
  25. package/skills/create-doc/SKILL.md +106 -0
  26. package/skills/create-theme/SKILL.md +184 -0
  27. package/skills/current-doc/SKILL.md +120 -0
  28. package/skills/doc-authoring/SKILL.md +434 -0
  29. package/skills/doc-authoring/references/assets.md +47 -0
  30. package/skills/doc-authoring/references/design-system.md +81 -0
  31. package/skills/doc-authoring/references/long-form.md +131 -0
  32. package/skills/doc-authoring/references/pagination.md +118 -0
  33. package/skills/doc-authoring/references/tables-and-charts.md +161 -0
  34. package/src/app/app.tsx +42 -0
  35. package/src/app/components/data-table.tsx +196 -0
  36. package/src/app/components/design-panel/design-panel.tsx +318 -0
  37. package/src/app/components/design-panel/design-provider.tsx +121 -0
  38. package/src/app/components/design-panel/use-design.ts +85 -0
  39. package/src/app/components/diagram.tsx +76 -0
  40. package/src/app/components/doc-assets.tsx +129 -0
  41. package/src/app/components/doc-search.tsx +248 -0
  42. package/src/app/components/doc-sidebar.tsx +162 -0
  43. package/src/app/components/flow-page.tsx +93 -0
  44. package/src/app/components/footnote.tsx +204 -0
  45. package/src/app/components/image-placeholder.tsx +50 -0
  46. package/src/app/components/inspector/inspector.tsx +518 -0
  47. package/src/app/components/numbering.tsx +224 -0
  48. package/src/app/components/page-frame.tsx +70 -0
  49. package/src/app/components/sidebar/folder-item.tsx +212 -0
  50. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  51. package/src/app/components/sidebar/sidebar.tsx +252 -0
  52. package/src/app/components/table-of-contents.tsx +93 -0
  53. package/src/app/components/theme-toggle.tsx +50 -0
  54. package/src/app/components/themes/markdown.tsx +249 -0
  55. package/src/app/components/themes/theme-preview.tsx +74 -0
  56. package/src/app/components/ui/menu.tsx +143 -0
  57. package/src/app/index.html +12 -0
  58. package/src/app/lib/agent-bridge.ts +140 -0
  59. package/src/app/lib/assets.ts +151 -0
  60. package/src/app/lib/design-presets.ts +109 -0
  61. package/src/app/lib/design.ts +88 -0
  62. package/src/app/lib/diagnostics.ts +282 -0
  63. package/src/app/lib/doc-preview.tsx +29 -0
  64. package/src/app/lib/docs.ts +26 -0
  65. package/src/app/lib/docx/extract.ts +1623 -0
  66. package/src/app/lib/docx/fonts.test.ts +136 -0
  67. package/src/app/lib/docx/fonts.ts +166 -0
  68. package/src/app/lib/docx/media.ts +102 -0
  69. package/src/app/lib/docx/model.ts +206 -0
  70. package/src/app/lib/docx/paragraph.test.ts +92 -0
  71. package/src/app/lib/docx/paragraph.ts +107 -0
  72. package/src/app/lib/docx/props.ts +187 -0
  73. package/src/app/lib/docx/styles.ts +306 -0
  74. package/src/app/lib/docx/units.ts +35 -0
  75. package/src/app/lib/docx/write.test.ts +507 -0
  76. package/src/app/lib/docx/write.ts +581 -0
  77. package/src/app/lib/docx/xml.ts +39 -0
  78. package/src/app/lib/export-docx.ts +289 -0
  79. package/src/app/lib/export-dom.ts +318 -0
  80. package/src/app/lib/export-html.ts +156 -0
  81. package/src/app/lib/export-image.ts +70 -0
  82. package/src/app/lib/export-pdf.ts +165 -0
  83. package/src/app/lib/flow-measure.test.ts +31 -0
  84. package/src/app/lib/flow-measure.ts +183 -0
  85. package/src/app/lib/flow.test.ts +110 -0
  86. package/src/app/lib/flow.ts +136 -0
  87. package/src/app/lib/folders.ts +192 -0
  88. package/src/app/lib/footnotes.test.tsx +102 -0
  89. package/src/app/lib/footnotes.ts +94 -0
  90. package/src/app/lib/inspector/fiber.ts +99 -0
  91. package/src/app/lib/labels.test.ts +18 -0
  92. package/src/app/lib/labels.ts +181 -0
  93. package/src/app/lib/outline.ts +118 -0
  94. package/src/app/lib/page-context.tsx +43 -0
  95. package/src/app/lib/page-range.test.ts +95 -0
  96. package/src/app/lib/page-range.ts +90 -0
  97. package/src/app/lib/print-ready.ts +69 -0
  98. package/src/app/lib/rasterize.ts +173 -0
  99. package/src/app/lib/scan.ts +26 -0
  100. package/src/app/lib/sdk.test.ts +32 -0
  101. package/src/app/lib/sdk.ts +115 -0
  102. package/src/app/lib/themes.ts +31 -0
  103. package/src/app/lib/use-doc-module.ts +53 -0
  104. package/src/app/lib/use-doc-pages.ts +147 -0
  105. package/src/app/lib/utils.ts +6 -0
  106. package/src/app/lib/view-mode.test.ts +91 -0
  107. package/src/app/lib/view-mode.ts +104 -0
  108. package/src/app/main.tsx +14 -0
  109. package/src/app/routes/assets.tsx +257 -0
  110. package/src/app/routes/doc.tsx +877 -0
  111. package/src/app/routes/home-shell.tsx +203 -0
  112. package/src/app/routes/home.tsx +269 -0
  113. package/src/app/routes/themes.tsx +121 -0
  114. package/src/app/styles.css +97 -0
  115. package/src/app/virtual.d.ts +30 -0
  116. package/template/AGENTS.md +27 -0
  117. package/template/README.md +39 -0
  118. package/template/docs/getting-started/index.tsx +230 -0
  119. package/template/mosage.config.ts +5 -0
  120. package/template/package.json +24 -0
  121. package/template/tsconfig.json +17 -0
  122. package/README.en.md +0 -57
  123. package/dist/cli.js +0 -4545
  124. package/dist/web/assets/index-Czg2WeHe.js +0 -182
  125. package/dist/web/assets/index-DKAyt92W.css +0 -1
  126. package/dist/web/index.html +0 -15
  127. package/skills/current-position/SKILL.md +0 -65
  128. package/skills/kickoff/SKILL.md +0 -105
  129. package/skills/mosage-reference/SKILL.md +0 -164
  130. package/skills/outline/SKILL.md +0 -79
  131. package/skills/review/SKILL.md +0 -64
  132. package/skills/write-chapter/SKILL.md +0 -58
  133. package/template/book/STYLE.md +0 -6
  134. package/template/book/assets/.gitkeep +0 -0
  135. package/template/book/book.yaml +0 -42
  136. package/template/book/brief.md +0 -6
  137. package/template/book/chapters/.gitkeep +0 -0
  138. package/template/book/notes/README.md +0 -7
  139. package/template/project/AGENTS.md +0 -82
  140. package/template/project/CLAUDE.md +0 -1
  141. package/template/project/README.md +0 -48
  142. package/template/project/books/.gitkeep +0 -0
  143. package/template/project/gitignore +0 -5
  144. package/template/project/mosage.yaml +0 -23
  145. package/template/project/notes/README.md +0 -8
  146. package/template/project/package.json +0 -14
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+ import type { DesignSystem } from '../lib/design';
3
+ import type { FlowSection } from '../lib/flow';
4
+ import type { ExtractedNote } from '../lib/footnotes';
5
+ import { PAGE_ATTR, PAGE_INDEX_ATTR } from '../lib/outline';
6
+ import { DocPageProvider, useDocPageCount } from '../lib/page-context';
7
+ import { Footnotes } from './footnote';
8
+
9
+ export const FLOW_BLOCK_ATTR = 'data-od-flow-block';
10
+ /** Around the section's footer, as `display: contents` so it lays out as if absent. */
11
+ export const FLOW_FOOTER_ATTR = 'data-od-flow-footer';
12
+
13
+ /**
14
+ * The page shell a flow section renders into. The framework owns the margin and
15
+ * base typography here — that is the trade for not hand-splitting pages — while
16
+ * the blocks keep their own styles.
17
+ *
18
+ * It is a column so the footnote area can sit at the foot of the sheet rather
19
+ * than immediately under the last paragraph. The blocks stay inside one block
20
+ * container, so their margins collapse exactly as they did when measured.
21
+ */
22
+ export function flowShellStyle(design: DesignSystem | undefined, padding?: number): CSSProperties {
23
+ return {
24
+ width: '100%',
25
+ height: '100%',
26
+ boxSizing: 'border-box',
27
+ padding: padding ?? design?.margin ?? 76,
28
+ background: 'var(--od-bg)',
29
+ color: 'var(--od-text)',
30
+ fontFamily: 'var(--od-font-body)',
31
+ fontSize: 'var(--od-size-body)',
32
+ lineHeight: 'var(--od-leading)',
33
+ position: 'relative',
34
+ overflow: 'hidden',
35
+ display: 'flex',
36
+ flexDirection: 'column',
37
+ };
38
+ }
39
+
40
+ /**
41
+ * With a `sheet`, the block is a page frame of its own: the outline and
42
+ * numbering scans, and `useDocPageNumber()`, see the sheet it printed on even
43
+ * when it is laid out in one continuous column.
44
+ */
45
+ export function FlowBlock({ children, sheet }: { children?: ReactNode; sheet?: number }) {
46
+ const total = useDocPageCount();
47
+ if (sheet === undefined) return <div {...{ [FLOW_BLOCK_ATTR]: '' }}>{children}</div>;
48
+ return (
49
+ <div {...{ [FLOW_BLOCK_ATTR]: '', [PAGE_ATTR]: '', [PAGE_INDEX_ATTR]: sheet }}>
50
+ <DocPageProvider index={sheet} total={total}>
51
+ {children}
52
+ </DocPageProvider>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export function FlowPage({
58
+ section,
59
+ design,
60
+ blockIndices,
61
+ blocks,
62
+ notes,
63
+ sheets,
64
+ }: {
65
+ section: FlowSection;
66
+ design: DesignSystem | undefined;
67
+ blockIndices: number[];
68
+ /** Blocks with footnotes already lifted out; falls back to the authored ones. */
69
+ blocks?: ReactNode[];
70
+ notes?: ExtractedNote[];
71
+ /** Block index → the sheet it printed on, for a copy laid out as one column. */
72
+ sheets?: ReadonlyMap<number, number>;
73
+ }) {
74
+ const Footer = section.footer;
75
+ const source = blocks ?? section.blocks;
76
+ return (
77
+ <div style={flowShellStyle(design, section.padding)}>
78
+ <div style={{ flex: 1, minHeight: 0 }}>
79
+ {blockIndices.map((index) => (
80
+ <FlowBlock key={index} sheet={sheets?.get(index)}>
81
+ {source[index]}
82
+ </FlowBlock>
83
+ ))}
84
+ </div>
85
+ {notes && notes.length > 0 && <Footnotes notes={notes} />}
86
+ {Footer && (
87
+ <div {...{ [FLOW_FOOTER_ATTR]: '' }} style={{ display: 'contents' }}>
88
+ <Footer />
89
+ </div>
90
+ )}
91
+ </div>
92
+ );
93
+ }