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,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
+ }