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.
- package/README.md +16 -15
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +59 -12
- package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
- package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
- package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +1 -1
- package/template/README.md +24 -52
- package/template/docs/getting-started/index.tsx +2 -2
- package/template/mosage.config.ts +1 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/README.zh-TW.md +0 -28
- /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import config from 'virtual:mosage/config';
|
|
2
|
+
import { Plus } from 'lucide-react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { Folder, FolderIcon } from '../../lib/sdk';
|
|
5
|
+
import { cn } from '../../lib/utils';
|
|
6
|
+
import { ThemeToggle } from '../theme-toggle';
|
|
7
|
+
import { FolderIconChip, FolderItem } from './folder-item';
|
|
8
|
+
import { IconPicker, PRESET_COLORS } from './icon-picker';
|
|
9
|
+
|
|
10
|
+
export const ALL_DOCS_ID = '__all__';
|
|
11
|
+
export const DRAFT_ID = 'draft';
|
|
12
|
+
export const THEMES_ID = '__themes__';
|
|
13
|
+
export const ASSETS_ID = '__assets__';
|
|
14
|
+
|
|
15
|
+
export const FOLDER_DND_MIME = 'application/x-mosage-folder-id';
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
folders: Folder[];
|
|
19
|
+
countFor: (folderId: string | null) => number;
|
|
20
|
+
allCount: number;
|
|
21
|
+
themesCount: number;
|
|
22
|
+
assetsCount: number;
|
|
23
|
+
selectedId: string;
|
|
24
|
+
onSelect: (id: string) => void;
|
|
25
|
+
onCreate: (name: string, icon: FolderIcon) => Promise<Folder>;
|
|
26
|
+
onRename: (id: string, name: string) => void;
|
|
27
|
+
onChangeIcon: (id: string, icon: FolderIcon) => void;
|
|
28
|
+
onDelete: (id: string) => void;
|
|
29
|
+
onDropToFolder: (folderId: string, docId: string) => void;
|
|
30
|
+
onDropToDraft: (docId: string) => void;
|
|
31
|
+
onReorder: (ids: string[]) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function Sidebar({
|
|
35
|
+
folders,
|
|
36
|
+
countFor,
|
|
37
|
+
allCount,
|
|
38
|
+
themesCount,
|
|
39
|
+
assetsCount,
|
|
40
|
+
selectedId,
|
|
41
|
+
onSelect,
|
|
42
|
+
onCreate,
|
|
43
|
+
onRename,
|
|
44
|
+
onChangeIcon,
|
|
45
|
+
onDelete,
|
|
46
|
+
onDropToFolder,
|
|
47
|
+
onDropToDraft,
|
|
48
|
+
onReorder,
|
|
49
|
+
}: Props) {
|
|
50
|
+
const [dragId, setDragId] = useState<string | null>(null);
|
|
51
|
+
const [dropTarget, setDropTarget] = useState<{ id: string; before: boolean } | null>(null);
|
|
52
|
+
const [creating, setCreating] = useState(false);
|
|
53
|
+
const [newName, setNewName] = useState('');
|
|
54
|
+
const [newIcon, setNewIcon] = useState<FolderIcon>({ type: 'color', value: PRESET_COLORS[0] });
|
|
55
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (creating) inputRef.current?.focus();
|
|
59
|
+
}, [creating]);
|
|
60
|
+
|
|
61
|
+
const finishReorder = (toId: string, before: boolean) => {
|
|
62
|
+
const fromId = dragId;
|
|
63
|
+
setDragId(null);
|
|
64
|
+
setDropTarget(null);
|
|
65
|
+
if (!fromId || fromId === toId) return;
|
|
66
|
+
const ids = folders.map((f) => f.id);
|
|
67
|
+
if (!ids.includes(fromId) || !ids.includes(toId)) return;
|
|
68
|
+
const next = ids.filter((id) => id !== fromId);
|
|
69
|
+
next.splice(next.indexOf(toId) + (before ? 0 : 1), 0, fromId);
|
|
70
|
+
if (next.every((id, i) => id === ids[i])) return;
|
|
71
|
+
onReorder(next);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const startCreating = () => {
|
|
75
|
+
setNewIcon({ type: 'color', value: PRESET_COLORS[folders.length % PRESET_COLORS.length] });
|
|
76
|
+
setNewName('');
|
|
77
|
+
setCreating(true);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const commitCreate = async () => {
|
|
81
|
+
const trimmed = newName.trim();
|
|
82
|
+
setCreating(false);
|
|
83
|
+
setNewName('');
|
|
84
|
+
if (!trimmed) return;
|
|
85
|
+
await onCreate(trimmed, newIcon).catch(() => {});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<aside className="flex h-full w-[16.5rem] shrink-0 flex-col border-border border-r bg-background">
|
|
90
|
+
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
|
91
|
+
<h1 className="font-semibold text-base tracking-tight">MoSage</h1>
|
|
92
|
+
<div className="-mr-1 flex items-center gap-0.5">
|
|
93
|
+
<ThemeToggle />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div className="px-2">
|
|
98
|
+
<FolderItem
|
|
99
|
+
row={{ kind: 'all' }}
|
|
100
|
+
count={allCount}
|
|
101
|
+
selected={selectedId === ALL_DOCS_ID}
|
|
102
|
+
onSelect={() => onSelect(ALL_DOCS_ID)}
|
|
103
|
+
onDropDoc={() => {}}
|
|
104
|
+
/>
|
|
105
|
+
<FolderItem
|
|
106
|
+
row={{ kind: 'themes' }}
|
|
107
|
+
count={themesCount}
|
|
108
|
+
selected={selectedId === THEMES_ID}
|
|
109
|
+
onSelect={() => onSelect(THEMES_ID)}
|
|
110
|
+
onDropDoc={() => {}}
|
|
111
|
+
/>
|
|
112
|
+
{import.meta.env.DEV && (
|
|
113
|
+
<FolderItem
|
|
114
|
+
row={{ kind: 'assets' }}
|
|
115
|
+
count={assetsCount}
|
|
116
|
+
selected={selectedId === ASSETS_ID}
|
|
117
|
+
onSelect={() => onSelect(ASSETS_ID)}
|
|
118
|
+
onDropDoc={() => {}}
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="mt-5 flex items-center gap-2 px-4 pb-1">
|
|
124
|
+
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">Folders</span>
|
|
125
|
+
<span className="h-px flex-1 bg-border" aria-hidden />
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
|
129
|
+
<FolderItem
|
|
130
|
+
row={{ kind: 'draft' }}
|
|
131
|
+
count={countFor(null)}
|
|
132
|
+
selected={selectedId === DRAFT_ID}
|
|
133
|
+
onSelect={() => onSelect(DRAFT_ID)}
|
|
134
|
+
onDropDoc={onDropToDraft}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
{folders.map((folder) => {
|
|
138
|
+
const isTarget = dropTarget?.id === folder.id;
|
|
139
|
+
return (
|
|
140
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: drag handle wraps the row, which keeps its own button
|
|
141
|
+
<div
|
|
142
|
+
key={folder.id}
|
|
143
|
+
className={cn(
|
|
144
|
+
'relative',
|
|
145
|
+
isTarget &&
|
|
146
|
+
dropTarget.before &&
|
|
147
|
+
'before:-top-px before:absolute before:inset-x-2 before:h-[2px] before:rounded-full before:bg-foreground',
|
|
148
|
+
isTarget &&
|
|
149
|
+
!dropTarget.before &&
|
|
150
|
+
'after:-bottom-px after:absolute after:inset-x-2 after:h-[2px] after:rounded-full after:bg-foreground',
|
|
151
|
+
dragId === folder.id && 'opacity-50',
|
|
152
|
+
)}
|
|
153
|
+
draggable={import.meta.env.DEV}
|
|
154
|
+
onDragStart={(e) => {
|
|
155
|
+
if (!import.meta.env.DEV) return;
|
|
156
|
+
e.dataTransfer.setData(FOLDER_DND_MIME, folder.id);
|
|
157
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
158
|
+
setDragId(folder.id);
|
|
159
|
+
}}
|
|
160
|
+
onDragEnd={() => {
|
|
161
|
+
setDragId(null);
|
|
162
|
+
setDropTarget(null);
|
|
163
|
+
}}
|
|
164
|
+
onDragOver={(e) => {
|
|
165
|
+
if (!e.dataTransfer.types.includes(FOLDER_DND_MIME)) return;
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
e.dataTransfer.dropEffect = 'move';
|
|
168
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
169
|
+
const before = e.clientY < rect.top + rect.height / 2;
|
|
170
|
+
if (!dropTarget || dropTarget.id !== folder.id || dropTarget.before !== before) {
|
|
171
|
+
setDropTarget({ id: folder.id, before });
|
|
172
|
+
}
|
|
173
|
+
}}
|
|
174
|
+
onDragLeave={(e) => {
|
|
175
|
+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
176
|
+
if (dropTarget?.id === folder.id) setDropTarget(null);
|
|
177
|
+
}}
|
|
178
|
+
onDrop={(e) => {
|
|
179
|
+
const fromId = e.dataTransfer.getData(FOLDER_DND_MIME);
|
|
180
|
+
if (!fromId) return;
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
e.stopPropagation();
|
|
183
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
184
|
+
finishReorder(folder.id, e.clientY < rect.top + rect.height / 2);
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<FolderItem
|
|
188
|
+
row={{
|
|
189
|
+
kind: 'folder',
|
|
190
|
+
folder,
|
|
191
|
+
onRename: (name) => onRename(folder.id, name),
|
|
192
|
+
onChangeIcon: (icon) => onChangeIcon(folder.id, icon),
|
|
193
|
+
onDelete: () => onDelete(folder.id),
|
|
194
|
+
}}
|
|
195
|
+
count={countFor(folder.id)}
|
|
196
|
+
selected={selectedId === folder.id}
|
|
197
|
+
onSelect={() => onSelect(folder.id)}
|
|
198
|
+
onDropDoc={(docId) => onDropToFolder(folder.id, docId)}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
|
|
204
|
+
{import.meta.env.DEV &&
|
|
205
|
+
(creating ? (
|
|
206
|
+
<div className="mt-1 flex items-center gap-2 rounded-md border border-foreground/30 border-dashed px-2 py-1">
|
|
207
|
+
<FolderIconChip icon={newIcon} />
|
|
208
|
+
<input
|
|
209
|
+
ref={inputRef}
|
|
210
|
+
value={newName}
|
|
211
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
212
|
+
onBlur={commitCreate}
|
|
213
|
+
onKeyDown={(e) => {
|
|
214
|
+
if (e.nativeEvent.isComposing) return;
|
|
215
|
+
if (e.key === 'Enter') void commitCreate();
|
|
216
|
+
if (e.key === 'Escape') {
|
|
217
|
+
setCreating(false);
|
|
218
|
+
setNewName('');
|
|
219
|
+
}
|
|
220
|
+
}}
|
|
221
|
+
placeholder="Folder name"
|
|
222
|
+
maxLength={40}
|
|
223
|
+
className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/60"
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
) : (
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={startCreating}
|
|
230
|
+
className="mt-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[12px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
231
|
+
>
|
|
232
|
+
<Plus className="size-3.5" />
|
|
233
|
+
New folder
|
|
234
|
+
</button>
|
|
235
|
+
))}
|
|
236
|
+
|
|
237
|
+
{creating && (
|
|
238
|
+
<div className="mt-1 rounded-md border border-border p-2">
|
|
239
|
+
<IconPicker value={newIcon} onChange={setNewIcon} />
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div className="border-border border-t px-4 py-3 text-[11px] text-muted-foreground">
|
|
245
|
+
<div className="flex items-center justify-between">
|
|
246
|
+
<span>v{config.version}</span>
|
|
247
|
+
<span className="font-mono">{import.meta.env.DEV ? 'dev' : 'static'}</span>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</aside>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import { TOC_ATTR, useDocOutline } from '../lib/outline';
|
|
3
|
+
import { useDocPageNumber } from '../lib/page-context';
|
|
4
|
+
|
|
5
|
+
export const TOC_ENTRY_ATTR = 'data-od-toc-entry';
|
|
6
|
+
/** Read by the Word export, which rebuilds the list as a contents field. */
|
|
7
|
+
export const TOC_DEPTH_ATTR = 'data-od-toc-depth';
|
|
8
|
+
export const TOC_LEVEL_ATTR = 'data-od-toc-level';
|
|
9
|
+
export const TOC_PAGE_ATTR = 'data-od-toc-page';
|
|
10
|
+
|
|
11
|
+
export type TableOfContentsProps = {
|
|
12
|
+
/** Deepest heading level to list. Defaults to 2 (h1 + h2). */
|
|
13
|
+
maxLevel?: number;
|
|
14
|
+
/** Show the page number column with dot leaders. Defaults to true. */
|
|
15
|
+
showPageNumbers?: boolean;
|
|
16
|
+
/** List headings that sit on the same page as the TOC itself. Defaults to false. */
|
|
17
|
+
includeOwnPage?: boolean;
|
|
18
|
+
/** Indent per heading level, in px. Defaults to 20. */
|
|
19
|
+
indent?: number;
|
|
20
|
+
style?: CSSProperties;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Renders the document's headings as a contents list. The outline is filled in
|
|
26
|
+
* by a DOM scan after the pages render, so this component is empty on the very
|
|
27
|
+
* first pass and populated on the next — including during PDF/HTML export,
|
|
28
|
+
* which scans before serializing.
|
|
29
|
+
*/
|
|
30
|
+
export function TableOfContents({
|
|
31
|
+
maxLevel = 2,
|
|
32
|
+
showPageNumbers = true,
|
|
33
|
+
includeOwnPage = false,
|
|
34
|
+
indent = 20,
|
|
35
|
+
style,
|
|
36
|
+
className,
|
|
37
|
+
}: TableOfContentsProps) {
|
|
38
|
+
const outline = useDocOutline();
|
|
39
|
+
const ownPage = useDocPageNumber();
|
|
40
|
+
|
|
41
|
+
const entries = outline.filter(
|
|
42
|
+
(entry) => entry.level <= maxLevel && (includeOwnPage || entry.page !== ownPage),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
{...{ [TOC_ATTR]: '', [TOC_DEPTH_ATTR]: maxLevel }}
|
|
48
|
+
className={className}
|
|
49
|
+
style={{
|
|
50
|
+
fontFamily: 'var(--od-font-body)',
|
|
51
|
+
fontSize: 'var(--od-size-body)',
|
|
52
|
+
color: 'var(--od-text)',
|
|
53
|
+
...style,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{entries.map((entry) => (
|
|
57
|
+
<div
|
|
58
|
+
key={entry.id}
|
|
59
|
+
/* Which heading this line stands for. The inspector follows it: a
|
|
60
|
+
* contents row holds no text of its own, so clicking one has to land
|
|
61
|
+
* on the heading that produced it. */
|
|
62
|
+
{...{ [TOC_ENTRY_ATTR]: entry.id, [TOC_LEVEL_ATTR]: entry.level }}
|
|
63
|
+
style={{
|
|
64
|
+
display: 'flex',
|
|
65
|
+
alignItems: 'baseline',
|
|
66
|
+
gap: 8,
|
|
67
|
+
paddingLeft: (entry.level - 1) * indent,
|
|
68
|
+
marginBottom: entry.level === 1 ? 10 : 6,
|
|
69
|
+
fontWeight: entry.level === 1 ? 600 : 400,
|
|
70
|
+
color: entry.level === 1 ? 'var(--od-text)' : 'var(--od-muted)',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<span>{entry.text}</span>
|
|
74
|
+
{showPageNumbers && (
|
|
75
|
+
<>
|
|
76
|
+
<span
|
|
77
|
+
aria-hidden
|
|
78
|
+
style={{
|
|
79
|
+
flex: 1,
|
|
80
|
+
borderBottom: '1px dotted var(--od-rule)',
|
|
81
|
+
transform: 'translateY(-3px)',
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
<span {...{ [TOC_PAGE_ATTR]: '' }} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
85
|
+
{entry.page}
|
|
86
|
+
</span>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Monitor, Moon, Sun } from 'lucide-react';
|
|
2
|
+
import { useTheme } from 'next-themes';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Menu, MenuItem } from './ui/menu';
|
|
5
|
+
|
|
6
|
+
const OPTIONS = [
|
|
7
|
+
{ value: 'light', label: 'Light', icon: Sun },
|
|
8
|
+
{ value: 'dark', label: 'Dark', icon: Moon },
|
|
9
|
+
{ value: 'system', label: 'System', icon: Monitor },
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export function ThemeToggle() {
|
|
13
|
+
const { theme, setTheme } = useTheme();
|
|
14
|
+
const [mounted, setMounted] = useState(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => setMounted(true), []);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Menu
|
|
20
|
+
trigger={(props) => (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
aria-label="Toggle theme"
|
|
24
|
+
title="Theme"
|
|
25
|
+
className="relative flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground aria-expanded:bg-accent"
|
|
26
|
+
{...props}
|
|
27
|
+
>
|
|
28
|
+
<Sun className="size-3.5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
29
|
+
<Moon className="absolute size-3.5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
30
|
+
</button>
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{(close) =>
|
|
34
|
+
OPTIONS.map(({ value, label, icon: Icon }) => (
|
|
35
|
+
<MenuItem
|
|
36
|
+
key={value}
|
|
37
|
+
active={mounted && theme === value}
|
|
38
|
+
onClick={() => {
|
|
39
|
+
setTheme(value);
|
|
40
|
+
close();
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<Icon className="size-3.5" />
|
|
44
|
+
{label}
|
|
45
|
+
</MenuItem>
|
|
46
|
+
))
|
|
47
|
+
}
|
|
48
|
+
</Menu>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type Block =
|
|
4
|
+
| { kind: 'heading'; level: number; text: string }
|
|
5
|
+
| { kind: 'code'; lang: string; text: string }
|
|
6
|
+
| { kind: 'list'; items: string[] }
|
|
7
|
+
| { kind: 'quote'; text: string }
|
|
8
|
+
| { kind: 'rule' }
|
|
9
|
+
| { kind: 'table'; head: string[]; rows: string[][] }
|
|
10
|
+
| { kind: 'paragraph'; text: string };
|
|
11
|
+
|
|
12
|
+
function splitRow(line: string): string[] {
|
|
13
|
+
return line
|
|
14
|
+
.trim()
|
|
15
|
+
.replace(/^\||\|$/g, '')
|
|
16
|
+
.split('|')
|
|
17
|
+
.map((cell) => cell.trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TABLE_DIVIDER_RE = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A theme file is prose written by the `create-theme` skill — headings, prose,
|
|
24
|
+
* lists, and paste-ready code. This renders exactly that much Markdown rather
|
|
25
|
+
* than pulling a parser into the runtime bundle.
|
|
26
|
+
*/
|
|
27
|
+
export function parseMarkdown(source: string): Block[] {
|
|
28
|
+
const lines = source.split(/\r?\n/);
|
|
29
|
+
const blocks: Block[] = [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
|
|
32
|
+
while (i < lines.length) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
|
|
35
|
+
if (line.trim() === '') {
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fence = line.match(/^```(\w*)/);
|
|
41
|
+
if (fence) {
|
|
42
|
+
const lang = fence[1] ?? '';
|
|
43
|
+
const body: string[] = [];
|
|
44
|
+
i++;
|
|
45
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
46
|
+
body.push(lines[i]);
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
i++; // closing fence
|
|
50
|
+
blocks.push({ kind: 'code', lang, text: body.join('\n') });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A pipe row followed by a divider row is a table. Theme files lean on
|
|
55
|
+
// tables for palettes and type scales, so leaving them as raw text makes
|
|
56
|
+
// the theme page unreadable.
|
|
57
|
+
if (line.trim().startsWith('|') && TABLE_DIVIDER_RE.test(lines[i + 1] ?? '')) {
|
|
58
|
+
const head = splitRow(line);
|
|
59
|
+
i += 2;
|
|
60
|
+
const rows: string[][] = [];
|
|
61
|
+
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
|
62
|
+
rows.push(splitRow(lines[i]));
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
blocks.push({ kind: 'table', head, rows });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
70
|
+
if (heading) {
|
|
71
|
+
blocks.push({ kind: 'heading', level: heading[1].length, text: heading[2].trim() });
|
|
72
|
+
i++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
|
|
77
|
+
blocks.push({ kind: 'rule' });
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (/^>\s?/.test(line)) {
|
|
83
|
+
const body: string[] = [];
|
|
84
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
85
|
+
body.push(lines[i].replace(/^>\s?/, ''));
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
blocks.push({ kind: 'quote', text: body.join(' ') });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
|
|
93
|
+
const items: string[] = [];
|
|
94
|
+
while (i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i])) {
|
|
95
|
+
items.push(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, ''));
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
blocks.push({ kind: 'list', items });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const body: string[] = [];
|
|
103
|
+
while (
|
|
104
|
+
i < lines.length &&
|
|
105
|
+
lines[i].trim() !== '' &&
|
|
106
|
+
!/^(#{1,6}\s|```|>\s?|\||\s*([-*+]|\d+\.)\s)/.test(lines[i])
|
|
107
|
+
) {
|
|
108
|
+
body.push(lines[i].trim());
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
blocks.push({ kind: 'paragraph', text: body.join(' ') });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return blocks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderInline(text: string): ReactNode[] {
|
|
118
|
+
const nodes: ReactNode[] = [];
|
|
119
|
+
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\))/g;
|
|
120
|
+
let last = 0;
|
|
121
|
+
let key = 0;
|
|
122
|
+
for (const match of text.matchAll(pattern)) {
|
|
123
|
+
const index = match.index ?? 0;
|
|
124
|
+
if (index > last) nodes.push(text.slice(last, index));
|
|
125
|
+
const token = match[0];
|
|
126
|
+
if (token.startsWith('`')) {
|
|
127
|
+
nodes.push(
|
|
128
|
+
<code
|
|
129
|
+
key={`i${key++}`}
|
|
130
|
+
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em] text-foreground"
|
|
131
|
+
>
|
|
132
|
+
{token.slice(1, -1)}
|
|
133
|
+
</code>,
|
|
134
|
+
);
|
|
135
|
+
} else if (token.startsWith('**')) {
|
|
136
|
+
nodes.push(
|
|
137
|
+
<strong key={`i${key++}`} className="font-semibold text-foreground">
|
|
138
|
+
{token.slice(2, -2)}
|
|
139
|
+
</strong>,
|
|
140
|
+
);
|
|
141
|
+
} else {
|
|
142
|
+
const label = token.slice(1, token.indexOf(']'));
|
|
143
|
+
const href = token.slice(token.indexOf('](') + 2, -1);
|
|
144
|
+
nodes.push(
|
|
145
|
+
<a key={`i${key++}`} href={href} className="underline underline-offset-2">
|
|
146
|
+
{label}
|
|
147
|
+
</a>,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
last = index + token.length;
|
|
151
|
+
}
|
|
152
|
+
if (last < text.length) nodes.push(text.slice(last));
|
|
153
|
+
return nodes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const HEADING_CLASS: Record<number, string> = {
|
|
157
|
+
1: 'mt-10 mb-3 font-semibold text-xl tracking-tight',
|
|
158
|
+
2: 'mt-8 mb-2.5 font-semibold text-base tracking-tight',
|
|
159
|
+
3: 'mt-6 mb-2 font-medium text-sm',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export function Markdown({ source }: { source: string }) {
|
|
163
|
+
const blocks = parseMarkdown(source);
|
|
164
|
+
return (
|
|
165
|
+
<div className="text-muted-foreground text-sm leading-relaxed">
|
|
166
|
+
{blocks.map((block, index) => {
|
|
167
|
+
const key = `${block.kind}-${index}`;
|
|
168
|
+
switch (block.kind) {
|
|
169
|
+
case 'heading': {
|
|
170
|
+
const className = HEADING_CLASS[block.level] ?? HEADING_CLASS[3];
|
|
171
|
+
return (
|
|
172
|
+
<p key={key} className={`text-foreground first:mt-0 ${className}`}>
|
|
173
|
+
{renderInline(block.text)}
|
|
174
|
+
</p>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
case 'code':
|
|
178
|
+
return (
|
|
179
|
+
<pre
|
|
180
|
+
key={key}
|
|
181
|
+
className="my-3 overflow-x-auto rounded-md border border-border bg-muted px-3 py-2.5 font-mono text-[11.5px] text-foreground leading-relaxed"
|
|
182
|
+
>
|
|
183
|
+
{block.text}
|
|
184
|
+
</pre>
|
|
185
|
+
);
|
|
186
|
+
case 'list':
|
|
187
|
+
return (
|
|
188
|
+
<ul key={key} className="my-3 list-disc space-y-1 pl-5">
|
|
189
|
+
{block.items.map((item, itemIndex) => (
|
|
190
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: parsed markdown has no stable id, and the list is re-rendered wholesale
|
|
191
|
+
<li key={`${key}-${itemIndex}`}>{renderInline(item)}</li>
|
|
192
|
+
))}
|
|
193
|
+
</ul>
|
|
194
|
+
);
|
|
195
|
+
case 'quote':
|
|
196
|
+
return (
|
|
197
|
+
<blockquote key={key} className="my-3 border-border border-l-2 pl-3 italic">
|
|
198
|
+
{renderInline(block.text)}
|
|
199
|
+
</blockquote>
|
|
200
|
+
);
|
|
201
|
+
case 'table':
|
|
202
|
+
return (
|
|
203
|
+
<div key={key} className="my-4 overflow-x-auto">
|
|
204
|
+
<table className="w-full border-collapse text-xs">
|
|
205
|
+
<thead>
|
|
206
|
+
<tr>
|
|
207
|
+
{block.head.map((cell, cellIndex) => (
|
|
208
|
+
<th
|
|
209
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: column position is the identity
|
|
210
|
+
key={`h${cellIndex}`}
|
|
211
|
+
className="border-border border-b px-2 py-1.5 text-left font-medium text-muted-foreground"
|
|
212
|
+
>
|
|
213
|
+
{renderInline(cell)}
|
|
214
|
+
</th>
|
|
215
|
+
))}
|
|
216
|
+
</tr>
|
|
217
|
+
</thead>
|
|
218
|
+
<tbody>
|
|
219
|
+
{block.rows.map((row, rowIndex) => (
|
|
220
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: row position is the identity
|
|
221
|
+
<tr key={`r${rowIndex}`}>
|
|
222
|
+
{row.map((cell, cellIndex) => (
|
|
223
|
+
<td
|
|
224
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: column position is the identity
|
|
225
|
+
key={`c${cellIndex}`}
|
|
226
|
+
className="border-border border-b px-2 py-1.5 align-top text-foreground"
|
|
227
|
+
>
|
|
228
|
+
{renderInline(cell)}
|
|
229
|
+
</td>
|
|
230
|
+
))}
|
|
231
|
+
</tr>
|
|
232
|
+
))}
|
|
233
|
+
</tbody>
|
|
234
|
+
</table>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
case 'rule':
|
|
238
|
+
return <hr key={key} className="my-6 border-border" />;
|
|
239
|
+
default:
|
|
240
|
+
return (
|
|
241
|
+
<p key={key} className="my-3">
|
|
242
|
+
{renderInline(block.text)}
|
|
243
|
+
</p>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
})}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|