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,69 @@
1
+ const DEFAULT_WAITFOR_TIMEOUT_MS = 10_000;
2
+
3
+ // `document.fonts.ready` already waits for every in-flight face. Never call
4
+ // `face.load()` on the rest: unloaded faces were never requested by CSS, and
5
+ // `load()` ignores `unicode-range`, so a subsetted CJK family (hundreds of
6
+ // faces) would be force-downloaded in full and hang the tab.
7
+ export async function waitForFonts(): Promise<void> {
8
+ if (!('fonts' in document)) return;
9
+ await document.fonts.ready;
10
+ }
11
+
12
+ export async function waitForImages(root: ParentNode, timeoutMs = 10_000): Promise<void> {
13
+ const images = Array.from(root.querySelectorAll('img'));
14
+ const pending = images.filter((img) => !img.complete);
15
+ if (pending.length === 0) return;
16
+ await Promise.race([
17
+ Promise.all(
18
+ pending.map(
19
+ (img) =>
20
+ new Promise<void>((resolve) => {
21
+ img.addEventListener('load', () => resolve(), { once: true });
22
+ img.addEventListener('error', () => resolve(), { once: true });
23
+ }),
24
+ ),
25
+ ),
26
+ sleep(timeoutMs),
27
+ ]);
28
+ }
29
+
30
+ export async function waitForDataWaitfor(
31
+ root: HTMLElement,
32
+ timeoutMs = DEFAULT_WAITFOR_TIMEOUT_MS,
33
+ ): Promise<void> {
34
+ const targets = Array.from(root.querySelectorAll<HTMLElement>('[data-waitfor]'));
35
+ if (targets.length === 0) return;
36
+ const deadline = performance.now() + timeoutMs;
37
+ await Promise.all(
38
+ targets.map(async (el) => {
39
+ const selector = el.getAttribute('data-waitfor');
40
+ if (!selector) return;
41
+ while (performance.now() < deadline) {
42
+ try {
43
+ if (el.querySelector(selector)) return;
44
+ } catch {
45
+ return; // invalid selector — skip rather than hang
46
+ }
47
+ await nextFrame();
48
+ }
49
+ }),
50
+ );
51
+ }
52
+
53
+ export function sleep(ms: number): Promise<void> {
54
+ return new Promise((resolve) => setTimeout(resolve, ms));
55
+ }
56
+
57
+ export function nextFrame(): Promise<void> {
58
+ // rAF in real tabs; setTimeout fallback for hidden/throttled tabs.
59
+ return new Promise((resolve) => {
60
+ let settled = false;
61
+ const settle = () => {
62
+ if (settled) return;
63
+ settled = true;
64
+ resolve();
65
+ };
66
+ requestAnimationFrame(settle);
67
+ setTimeout(settle, 50);
68
+ });
69
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Page content as a picture — SVG, and PNG rasterised from it.
3
+ *
4
+ * The page is HTML, so the SVG is an HTML page wrapped in `<foreignObject>`.
5
+ * That has one hard consequence: an SVG handed to the canvas rasteriser is
6
+ * loaded in isolation, and it may not fetch anything. A stylesheet URL, a web
7
+ * font, an `<img src>` pointing at the server — all of them silently do not
8
+ * arrive, and what comes out is a page in fallback fonts with holes where the
9
+ * images were. So every referenced asset is fetched here and embedded as a
10
+ * `data:` URI before the SVG is built. Fonts installed on the reader's own
11
+ * machine still resolve by name; only fetched ones need embedding.
12
+ */
13
+
14
+ import { designToCssVars } from './design';
15
+ import { findCssAssetUrls, findHtmlAssetUrls, toAbsolute } from './export-dom';
16
+ import type { DocModule } from './sdk';
17
+
18
+ type Size = { width: number; height: number };
19
+
20
+ /**
21
+ * Rasterised at twice the CSS size. A page printed at 1x is legible on screen
22
+ * and disappointing everywhere else — the moment someone drops it into a slide
23
+ * the text is soft. Twice is the cheapest size that survives that.
24
+ */
25
+ const SCALE = 2;
26
+
27
+ const FETCHES = 8;
28
+
29
+ /** Every asset the markup and the stylesheet reference, fetched and inlined as a `data:` URI. */
30
+ export async function inlineAssets(
31
+ sourceCss: string,
32
+ pagesHtml: string[],
33
+ ): Promise<{ css: string; html: string[] }> {
34
+ const joined = pagesHtml.join('\n');
35
+ const urls = new Set<string>([...findHtmlAssetUrls(joined), ...findCssAssetUrls(sourceCss)]);
36
+
37
+ const replacements = new Map<string, string>();
38
+ const queue = [...urls];
39
+ const fetchNext = async (): Promise<void> => {
40
+ for (let url = queue.shift(); url !== undefined; url = queue.shift()) {
41
+ const absolute = toAbsolute(url);
42
+ if (!absolute) continue;
43
+ try {
44
+ const res = await fetch(absolute);
45
+ if (!res.ok) continue;
46
+ replacements.set(url, await blobToDataUrl(await res.blob()));
47
+ } catch {
48
+ /* An asset that will not load is left as it was: a broken picture in the
49
+ output is easier to diagnose than a silently missing one. */
50
+ }
51
+ }
52
+ };
53
+ // A few at a time. A self-hosted CJK family lists a thousand-odd subsets, and
54
+ // fetched all at once they exhaust Chromium's per-renderer request limit: the
55
+ // tail fails, and those glyphs silently fall back to another face.
56
+ await Promise.all(Array.from({ length: Math.min(FETCHES, queue.length) }, fetchNext));
57
+ if (replacements.size === 0) return { css: sourceCss, html: pagesHtml };
58
+
59
+ // One pass over each string. Replacing URL by URL rescans text that grows
60
+ // with every inlined font — quadratic once a CJK family brings hundreds.
61
+ const pattern = new RegExp(
62
+ [...replacements.keys()]
63
+ .sort((a, b) => b.length - a.length)
64
+ .map((url) => url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
65
+ .join('|'),
66
+ 'g',
67
+ );
68
+ const inline = (text: string) => text.replace(pattern, (url) => replacements.get(url) ?? url);
69
+ return { css: inline(sourceCss), html: pagesHtml.map(inline) };
70
+ }
71
+
72
+ function blobToDataUrl(blob: Blob): Promise<string> {
73
+ return new Promise((resolve, reject) => {
74
+ const reader = new FileReader();
75
+ reader.onload = () => resolve(String(reader.result));
76
+ reader.onerror = () => reject(reader.error);
77
+ reader.readAsDataURL(blob);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * The design variables live on the page host, and renderPagesToHtml returns the
83
+ * host's innerHTML, so they are not in the markup and have to be put back.
84
+ */
85
+ export function designDeclarations(doc: DocModule): string {
86
+ const vars = doc.design ? designToCssVars(doc.design) : null;
87
+ return vars
88
+ ? Object.entries(vars)
89
+ .map(([name, value]) => `${name}:${value}`)
90
+ .join(';')
91
+ : '';
92
+ }
93
+
94
+ /**
95
+ * The markup has to come out as XML, not HTML.
96
+ *
97
+ * `foreignObject` content is parsed by the XML parser, which stops at the first
98
+ * `<br>` or `<img>` that never closes — and the failure arrives as an image that
99
+ * will not load, with nothing said about why. So the page is parsed as HTML into
100
+ * a real tree and serialised back out as XML, which closes those tags properly.
101
+ */
102
+ export function htmlToSvg(pageHtml: string, css: string, size: Size, declarations: string): string {
103
+ const SVG_NS = 'http://www.w3.org/2000/svg';
104
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
105
+
106
+ const svg = document.createElementNS(SVG_NS, 'svg');
107
+ svg.setAttribute('width', String(size.width));
108
+ svg.setAttribute('height', String(size.height));
109
+ svg.setAttribute('viewBox', `0 0 ${size.width} ${size.height}`);
110
+
111
+ const foreign = document.createElementNS(SVG_NS, 'foreignObject');
112
+ foreign.setAttribute('x', '0');
113
+ foreign.setAttribute('y', '0');
114
+ foreign.setAttribute('width', String(size.width));
115
+ foreign.setAttribute('height', String(size.height));
116
+
117
+ const wrapper = document.createElementNS(XHTML_NS, 'div');
118
+ wrapper.setAttribute('style', `${declarations};width:${size.width}px;height:${size.height}px`);
119
+
120
+ const style = document.createElementNS(XHTML_NS, 'style');
121
+ style.appendChild(document.createTextNode(css));
122
+ wrapper.appendChild(style);
123
+
124
+ const content = document.createElementNS(XHTML_NS, 'div');
125
+ content.innerHTML = pageHtml;
126
+ wrapper.appendChild(content);
127
+
128
+ foreign.appendChild(wrapper);
129
+ svg.appendChild(foreign);
130
+ return new XMLSerializer().serializeToString(svg);
131
+ }
132
+
133
+ /**
134
+ * SVG → canvas → PNG.
135
+ *
136
+ * The SVG goes in as a `data:` URI, not a `blob:` one. Chrome taints a canvas
137
+ * that has been drawn from a blob-backed SVG containing `<foreignObject>`, and
138
+ * refuses to export it; the same markup as a data URI draws and exports fine.
139
+ * Verified against Chrome 151 — a plain SVG is clean either way, so the taint
140
+ * follows the foreignObject and the URL scheme together, not either alone.
141
+ */
142
+ export async function rasterise(svg: string, size: Size): Promise<Blob> {
143
+ const source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
144
+
145
+ const image = new Image();
146
+ await new Promise<void>((resolve, reject) => {
147
+ image.onload = () => resolve();
148
+ image.onerror = () => reject(new Error('The page could not be drawn as an image.'));
149
+ image.src = source;
150
+ });
151
+ return drawToPng(image, size);
152
+ }
153
+
154
+ export async function drawToPng(image: CanvasImageSource, size: Size): Promise<Blob> {
155
+ const canvas = document.createElement('canvas');
156
+ canvas.width = Math.round(size.width * SCALE);
157
+ canvas.height = Math.round(size.height * SCALE);
158
+ const ctx = canvas.getContext('2d');
159
+ if (!ctx) throw new Error('This browser would not give a 2D canvas.');
160
+ /* A page is paper. A transparent PNG dropped on a dark slide shows black
161
+ body text on black. */
162
+ ctx.fillStyle = '#ffffff';
163
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
164
+ ctx.setTransform(SCALE, 0, 0, SCALE, 0, 0);
165
+ ctx.drawImage(image, 0, 0, size.width, size.height);
166
+
167
+ return await new Promise<Blob>((resolve, reject) => {
168
+ canvas.toBlob(
169
+ (blob) => (blob ? resolve(blob) : reject(new Error('The canvas produced no image.'))),
170
+ 'image/png',
171
+ );
172
+ });
173
+ }
@@ -0,0 +1,26 @@
1
+ import { collectLabels, getLabels, type LabelSnapshot, setLabels } from './labels';
2
+ import { collectOutline, getOutline, type OutlineEntry, setOutline } from './outline';
3
+ import type { DocMeta } from './sdk';
4
+
5
+ export type ScanSnapshot = { outline: OutlineEntry[]; labels: LabelSnapshot };
6
+
7
+ /**
8
+ * Reads everything the rendered pages know that the source does not: which
9
+ * headings exist and where they landed, and what number each figure, table, and
10
+ * footnote ended up with. One call, because the two scans must always describe
11
+ * the same copy of the document — the viewer's pages, or an exporter's private
12
+ * one.
13
+ */
14
+ export function scanDocument(root: ParentNode, meta?: DocMeta): void {
15
+ setOutline(collectOutline(root));
16
+ setLabels(collectLabels(root), meta?.labels);
17
+ }
18
+
19
+ export function captureScan(): ScanSnapshot {
20
+ return { outline: getOutline(), labels: getLabels() };
21
+ }
22
+
23
+ export function restoreScan(snapshot: ScanSnapshot): void {
24
+ setOutline(snapshot.outline);
25
+ setLabels(snapshot.labels.entries, snapshot.labels.vocabulary);
26
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { PAGE_SIZE_NAMES, PAGE_SIZES, resolvePageGeometry } from './sdk.ts';
3
+
4
+ describe('resolvePageGeometry', () => {
5
+ it('defaults to portrait A4', () => {
6
+ expect(resolvePageGeometry()).toEqual({
7
+ width: PAGE_SIZES.A4.width,
8
+ height: PAGE_SIZES.A4.height,
9
+ mm: [210, 297],
10
+ css: '210mm 297mm',
11
+ });
12
+ });
13
+
14
+ it('offers A4, B4 and A3 and nothing else', () => {
15
+ expect([...PAGE_SIZE_NAMES]).toEqual(['A4', 'B4', 'A3']);
16
+ expect(Object.keys(PAGE_SIZES)).toEqual([...PAGE_SIZE_NAMES]);
17
+ });
18
+
19
+ it('swaps both the pixel axes and the @page descriptor for landscape', () => {
20
+ const geo = resolvePageGeometry({ pageSize: 'B4', orientation: 'landscape' });
21
+ expect(geo.width).toBe(PAGE_SIZES.B4.height);
22
+ expect(geo.height).toBe(PAGE_SIZES.B4.width);
23
+ // Chromium drops `<mm> <mm> landscape` entirely — the axes must be swapped.
24
+ expect(geo.css).toBe('364mm 257mm');
25
+ expect(geo.mm).toEqual([364, 257]);
26
+ });
27
+
28
+ it('falls back to A4 for a page size that is no longer offered', () => {
29
+ const geo = resolvePageGeometry({ pageSize: 'Letter' as never });
30
+ expect(geo.width).toBe(PAGE_SIZES.A4.width);
31
+ });
32
+ });
@@ -0,0 +1,115 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import type { DesignSystem } from './design.ts';
3
+ import type { LabelVocabulary } from './labels.ts';
4
+
5
+ /** The only sheets a document may be laid out on. */
6
+ export const PAGE_SIZE_NAMES = ['A4', 'B4', 'A3'] as const;
7
+
8
+ export type PageSizeName = (typeof PAGE_SIZE_NAMES)[number];
9
+
10
+ export const ORIENTATIONS = ['portrait', 'landscape'] as const;
11
+
12
+ export type Orientation = (typeof ORIENTATIONS)[number];
13
+
14
+ /**
15
+ * Portrait dimensions in CSS pixels at 96dpi — the unit authors write in —
16
+ * paired with the physical millimetres used when printing. Keeping both means a
17
+ * page laid out at 794×1123 on screen maps to a real A4 sheet with no rescaling.
18
+ */
19
+ export const PAGE_SIZES: Record<
20
+ PageSizeName,
21
+ { width: number; height: number; mm: readonly [number, number] }
22
+ > = {
23
+ A4: { width: 794, height: 1123, mm: [210, 297] },
24
+ // JIS B4, not the ISO B4 (250×353mm) that the CSS `size: B4` keyword means —
25
+ // which is why the descriptor is written in millimetres rather than by name.
26
+ B4: { width: 971, height: 1376, mm: [257, 364] },
27
+ A3: { width: 1123, height: 1587, mm: [297, 420] },
28
+ };
29
+
30
+ export const DEFAULT_PAGE_SIZE: PageSizeName = 'A4';
31
+
32
+ export const DEFAULT_ORIENTATION: Orientation = 'portrait';
33
+
34
+ export function isPageSizeName(value: unknown): value is PageSizeName {
35
+ return typeof value === 'string' && value in PAGE_SIZES;
36
+ }
37
+
38
+ export function isOrientation(value: unknown): value is Orientation {
39
+ return value === 'portrait' || value === 'landscape';
40
+ }
41
+
42
+ export type PageGeometry = {
43
+ width: number;
44
+ height: number;
45
+ /** The printed sheet in millimetres, across then down — what a Word section is sized in. */
46
+ mm: readonly [number, number];
47
+ /** Value for the `@page { size: … }` descriptor, orientation included. */
48
+ css: string;
49
+ };
50
+
51
+ export type DocPage = ComponentType;
52
+
53
+ /**
54
+ * A run of continuous content the framework paginates itself. Build one with
55
+ * `flow(...)` and put it in the page array alongside fixed pages.
56
+ */
57
+ export type FlowSection = {
58
+ readonly __odFlow: true;
59
+ blocks: ReactNode[];
60
+ /** Rendered on every page the section expands into. */
61
+ footer?: ComponentType;
62
+ /** Page padding override in px; defaults to the design's `margin`. */
63
+ padding?: number;
64
+ };
65
+
66
+ /** What a document's default export may contain: fixed pages, flow sections, or both. */
67
+ export type DocEntry = DocPage | FlowSection;
68
+
69
+ export type DocMeta = {
70
+ title?: string;
71
+ subtitle?: string;
72
+ author?: string;
73
+ pageSize?: PageSizeName;
74
+ orientation?: Orientation;
75
+ /** Id of a theme under `themes/` this document was built from. Adds a back-link. */
76
+ theme?: string;
77
+ /** ISO 8601 timestamp. Set once at scaffold time; used to sort the doc list. */
78
+ createdAt?: string;
79
+ /** What numbered items are called — `圖`/`表` instead of `Figure`/`Table`. */
80
+ labels?: Partial<LabelVocabulary>;
81
+ };
82
+
83
+ export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
84
+
85
+ export type Folder = {
86
+ id: string;
87
+ name: string;
88
+ icon: FolderIcon;
89
+ };
90
+
91
+ export type FoldersManifest = {
92
+ folders: Folder[];
93
+ assignments: Record<string, string>;
94
+ };
95
+
96
+ export type DocModule = {
97
+ default: DocEntry[];
98
+ meta?: DocMeta;
99
+ design?: DesignSystem;
100
+ };
101
+
102
+ export function resolvePageGeometry(meta?: DocMeta): PageGeometry {
103
+ const size = PAGE_SIZES[meta?.pageSize ?? DEFAULT_PAGE_SIZE] ?? PAGE_SIZES[DEFAULT_PAGE_SIZE];
104
+ const landscape = meta?.orientation === 'landscape';
105
+ const [across, down] = landscape ? [size.mm[1], size.mm[0]] : size.mm;
106
+ return {
107
+ width: landscape ? size.height : size.width,
108
+ height: landscape ? size.width : size.height,
109
+ mm: [across, down],
110
+ // Not `<mm> <mm> landscape`: the `landscape` keyword is only valid next to a
111
+ // page-size *name*, and Chromium drops the whole descriptor if it sees both,
112
+ // which silently prints a landscape sheet at the dialog's default size.
113
+ css: `${across}mm ${down}mm`,
114
+ };
115
+ }
@@ -0,0 +1,31 @@
1
+ import { loadThemeDemo as load, themes as raw } from 'virtual:mosage/themes';
2
+ import type { DesignSystem } from './design';
3
+ import type { DocPage } from './sdk';
4
+
5
+ export type ThemeMeta = {
6
+ id: string;
7
+ name: string;
8
+ description: string;
9
+ /** Frontmatter hint — 'A4' | 'Letter' | … or '' when the theme doesn't say. */
10
+ pageSize: string;
11
+ /** Frontmatter hint — 'light' | 'dark' | '' */
12
+ mode: string;
13
+ body: string;
14
+ hasDemo: boolean;
15
+ };
16
+
17
+ export type ThemeDemoModule = {
18
+ default: DocPage[];
19
+ design?: DesignSystem;
20
+ };
21
+
22
+ export const themes: ThemeMeta[] = raw;
23
+
24
+ export function findTheme(id: string | undefined): ThemeMeta | undefined {
25
+ if (!id) return undefined;
26
+ return themes.find((t) => t.id === id);
27
+ }
28
+
29
+ export async function loadThemeDemo(id: string): Promise<ThemeDemoModule> {
30
+ return load(id);
31
+ }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { docChangeIncludes, loadDoc } from './docs';
3
+ import type { DocModule } from './sdk';
4
+
5
+ export type DocState =
6
+ | { status: 'loading'; doc: null; error: null }
7
+ | { status: 'ready'; doc: DocModule; error: null }
8
+ | { status: 'error'; doc: null; error: Error };
9
+
10
+ const LOADING: DocState = { status: 'loading', doc: null, error: null };
11
+
12
+ export function useDocModule(docId: string | undefined): DocState {
13
+ const [state, setState] = useState<DocState>(LOADING);
14
+
15
+ useEffect(() => {
16
+ if (!docId) return;
17
+ let cancelled = false;
18
+
19
+ const read = () => {
20
+ loadDoc(docId)
21
+ .then((doc) => {
22
+ if (!cancelled) setState({ status: 'ready', doc, error: null });
23
+ })
24
+ .catch((err: unknown) => {
25
+ if (cancelled) return;
26
+ setState({
27
+ status: 'error',
28
+ doc: null,
29
+ error: err instanceof Error ? err : new Error(String(err)),
30
+ });
31
+ });
32
+ };
33
+
34
+ setState(LOADING);
35
+ read();
36
+
37
+ if (!import.meta.hot)
38
+ return () => {
39
+ cancelled = true;
40
+ };
41
+
42
+ const onChange = (data: unknown) => {
43
+ if (docChangeIncludes(data, docId)) read();
44
+ };
45
+ import.meta.hot.on('mosage:doc-changed', onChange);
46
+ return () => {
47
+ cancelled = true;
48
+ import.meta.hot?.off('mosage:doc-changed', onChange);
49
+ };
50
+ }, [docId]);
51
+
52
+ return state;
53
+ }
@@ -0,0 +1,147 @@
1
+ import { createElement, type ReactNode, useEffect, useMemo, useState } from 'react';
2
+ import { FlowPage } from '../components/flow-page';
3
+ import type { DesignSystem } from './design';
4
+ import { type DocEntry, type FlowSection, isFlowSection, paginateBlocks } from './flow';
5
+ import { type MeasurableSection, measureFlowSections } from './flow-measure';
6
+ import {
7
+ type ExtractedNote,
8
+ extractSectionFootnotes,
9
+ notesForPage,
10
+ type PreparedSection,
11
+ } from './footnotes';
12
+ import type { DocModule, PageGeometry } from './sdk';
13
+
14
+ /** The slice of a flow section one sheet shows. */
15
+ export type FlowSlice = {
16
+ /** Index into the document's entries; every sheet of one section shares it. */
17
+ entry: number;
18
+ section: FlowSection;
19
+ blocks: ReactNode[];
20
+ blockIndices: number[];
21
+ notes: ExtractedNote[];
22
+ };
23
+
24
+ export type ExpandedPage = {
25
+ key: string;
26
+ content: ReactNode;
27
+ /** Set on a flow section's sheets, so an exporter that reflows can join them up again. */
28
+ flow?: FlowSlice;
29
+ };
30
+
31
+ type Plan = {
32
+ /** Section index → block indices per page. */
33
+ bySection: number[][][];
34
+ overflowing: Array<{ section: number; block: number }>;
35
+ };
36
+
37
+ const EMPTY_PLAN: Plan = { bySection: [], overflowing: [] };
38
+
39
+ /** A plan is only current for the sections it was measured from. */
40
+ type Measured = { plan: Plan; sections: FlowSection[] | null };
41
+
42
+ const NOT_MEASURED: Measured = { plan: EMPTY_PLAN, sections: null };
43
+
44
+ function entriesOf(doc: DocModule | null): DocEntry[] {
45
+ return (doc?.default ?? []) as DocEntry[];
46
+ }
47
+
48
+ /**
49
+ * Turns the authored entry list into the pages actually rendered: fixed page
50
+ * components pass through, flow sections expand into as many pages as their
51
+ * measured content needs.
52
+ */
53
+ export function useDocPages(
54
+ doc: DocModule | null,
55
+ geometry: PageGeometry,
56
+ ): { pages: ExpandedPage[]; measuring: boolean; overflowing: Plan['overflowing'] } {
57
+ const entries = useMemo(() => entriesOf(doc), [doc]);
58
+ const sections = useMemo(() => entries.filter(isFlowSection), [entries]);
59
+ const design = doc?.design as DesignSystem | undefined;
60
+
61
+ // Footnotes come out of the blocks before anything is measured: what they
62
+ // cost at the foot of a page is part of that page's budget.
63
+ const prepared = useMemo<PreparedSection[]>(
64
+ () => sections.map((section, index) => extractSectionFootnotes(section.blocks, index)),
65
+ [sections],
66
+ );
67
+ const measurable = useMemo<MeasurableSection[]>(
68
+ () =>
69
+ sections.map((section, index) => ({
70
+ blocks: prepared[index].blocks,
71
+ notesByBlock: prepared[index].notesByBlock,
72
+ ...(section.padding !== undefined ? { padding: section.padding } : {}),
73
+ })),
74
+ [sections, prepared],
75
+ );
76
+
77
+ const [state, setState] = useState<Measured>(NOT_MEASURED);
78
+ // Derived, not stored: a `measuring` flag set from an effect stays false for
79
+ // one commit after the document loads, and anything reading the page list in
80
+ // that window — the outline scan, the headless bridge — sees a whole flow
81
+ // section as one unpaginated page.
82
+ const measuring = sections.length > 0 && state.sections !== sections;
83
+
84
+ useEffect(() => {
85
+ if (sections.length === 0) {
86
+ setState({ plan: EMPTY_PLAN, sections });
87
+ return;
88
+ }
89
+ let cancelled = false;
90
+ measureFlowSections(measurable, { geometry, design })
91
+ .then((measurements) => {
92
+ if (cancelled) return;
93
+ const bySection: number[][][] = [];
94
+ const overflowing: Plan['overflowing'] = [];
95
+ measurements.forEach((measurement, sectionIndex) => {
96
+ const result = paginateBlocks(measurement.metrics, measurement.available, {
97
+ footnoteOverhead: measurement.footnoteOverhead,
98
+ });
99
+ bySection.push(result.pages);
100
+ for (const block of result.overflowing) {
101
+ overflowing.push({ section: sectionIndex, block });
102
+ }
103
+ });
104
+ setState({ plan: { bySection, overflowing }, sections });
105
+ })
106
+ .catch(() => {
107
+ if (!cancelled) setState({ plan: EMPTY_PLAN, sections });
108
+ });
109
+ return () => {
110
+ cancelled = true;
111
+ };
112
+ }, [sections, measurable, geometry, design]);
113
+
114
+ const plan = state.plan;
115
+
116
+ const pages = useMemo(() => {
117
+ const out: ExpandedPage[] = [];
118
+ let sectionIndex = -1;
119
+
120
+ entries.forEach((entry, entryIndex) => {
121
+ if (!isFlowSection(entry)) {
122
+ const Page = entry;
123
+ out.push({ key: `p${entryIndex}`, content: createElement(Page) });
124
+ return;
125
+ }
126
+ sectionIndex++;
127
+ const section = entry as FlowSection;
128
+ const ready = prepared[sectionIndex];
129
+ const blocks = ready?.blocks ?? section.blocks;
130
+ // Before measurement lands, render the section as a single page so the
131
+ // viewer shows something rather than flashing empty.
132
+ const chunks = plan.bySection[sectionIndex] ?? [blocks.map((_, i) => i)];
133
+ chunks.forEach((blockIndices, pageIndex) => {
134
+ const notes = ready ? notesForPage(ready.notesByBlock, blockIndices) : [];
135
+ out.push({
136
+ key: `f${entryIndex}-${pageIndex}`,
137
+ content: createElement(FlowPage, { section, design, blockIndices, blocks, notes }),
138
+ flow: { entry: entryIndex, section, blocks, blockIndices, notes },
139
+ });
140
+ });
141
+ });
142
+
143
+ return out;
144
+ }, [entries, plan, prepared, design]);
145
+
146
+ return { pages, measuring, overflowing: plan.overflowing };
147
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]): string {
5
+ return twMerge(clsx(inputs));
6
+ }