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,99 @@
|
|
|
1
|
+
export type SourceLoc = { line: number; column: number };
|
|
2
|
+
|
|
3
|
+
type DebugSource = { fileName?: string; lineNumber?: number; columnNumber?: number };
|
|
4
|
+
|
|
5
|
+
type Fiber = {
|
|
6
|
+
type?: unknown;
|
|
7
|
+
return?: Fiber | null;
|
|
8
|
+
stateNode?: unknown;
|
|
9
|
+
_debugSource?: DebugSource | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const LOC_ATTR = 'data-od-loc';
|
|
13
|
+
|
|
14
|
+
function fiberOf(el: HTMLElement): Fiber | null {
|
|
15
|
+
const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'));
|
|
16
|
+
return key ? ((el as unknown as Record<string, Fiber>)[key] ?? null) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function locFromAttr(value: string | null): SourceLoc | null {
|
|
20
|
+
if (!value) return null;
|
|
21
|
+
const [line, column] = value.split(':').map(Number);
|
|
22
|
+
if (!Number.isFinite(line) || !Number.isFinite(column)) return null;
|
|
23
|
+
return { line, column };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function debugLoc(fiber: Fiber): (SourceLoc & { file?: string }) | null {
|
|
27
|
+
const source = fiber._debugSource;
|
|
28
|
+
if (!source || typeof source.lineNumber !== 'number' || typeof source.columnNumber !== 'number') {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return { line: source.lineNumber, column: source.columnNumber, file: source.fileName };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Source locations to try for the clicked element, innermost first.
|
|
36
|
+
*
|
|
37
|
+
* The DOM only carries a loc for host elements, so a `<td>` rendered by a local
|
|
38
|
+
* `<Td>text</Td>` helper points at the helper's definition — where the text
|
|
39
|
+
* isn't. Walking the fiber's `return` chain surfaces the call site, which is
|
|
40
|
+
* where the author actually wrote the words.
|
|
41
|
+
*
|
|
42
|
+
* React's `_debugSource` counts lines against the module *after* the dev
|
|
43
|
+
* transforms prepend their preamble, so it never matches our loc tags
|
|
44
|
+
* directly. Rather than hard-coding that offset, calibrate it from any fiber
|
|
45
|
+
* that carries both values.
|
|
46
|
+
*/
|
|
47
|
+
export function candidateLocs(el: HTMLElement, maxDepth = 12): SourceLoc[] {
|
|
48
|
+
const domLoc = locFromAttr(el.closest(`[${LOC_ATTR}]`)?.getAttribute(LOC_ATTR) ?? null);
|
|
49
|
+
const candidates: SourceLoc[] = domLoc ? [domLoc] : [];
|
|
50
|
+
|
|
51
|
+
let fiber = fiberOf(el);
|
|
52
|
+
const chain: Fiber[] = [];
|
|
53
|
+
for (let depth = 0; fiber && depth < maxDepth; depth++) {
|
|
54
|
+
chain.push(fiber);
|
|
55
|
+
fiber = fiber.return ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Calibrate against a fiber that carries both values — that one is provably
|
|
59
|
+
// in the document's own source file, which also tells us which file the rest
|
|
60
|
+
// of the chain has to belong to.
|
|
61
|
+
let calibration: { line: number; column: number; file?: string } | null = null;
|
|
62
|
+
for (const node of chain) {
|
|
63
|
+
const source = debugLoc(node);
|
|
64
|
+
const host = node.stateNode instanceof HTMLElement ? node.stateNode : null;
|
|
65
|
+
const tagged = locFromAttr(host?.getAttribute(LOC_ATTR) ?? null);
|
|
66
|
+
if (source && tagged) {
|
|
67
|
+
calibration = {
|
|
68
|
+
line: source.line - tagged.line,
|
|
69
|
+
column: source.column - tagged.column,
|
|
70
|
+
file: source.file,
|
|
71
|
+
};
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!calibration) return candidates;
|
|
76
|
+
|
|
77
|
+
for (const node of chain) {
|
|
78
|
+
const source = debugLoc(node);
|
|
79
|
+
if (!source) continue;
|
|
80
|
+
// Fibers from the framework's own components (FlowPage, PageFrame, …) carry
|
|
81
|
+
// line numbers from *their* files. Mapping those through the document's
|
|
82
|
+
// offset lands on unrelated elements, which is how an edit ends up
|
|
83
|
+
// rewriting the wrong text.
|
|
84
|
+
if (calibration.file && source.file !== calibration.file) continue;
|
|
85
|
+
const loc = {
|
|
86
|
+
line: source.line - calibration.line,
|
|
87
|
+
column: source.column - calibration.column,
|
|
88
|
+
};
|
|
89
|
+
if (loc.line < 1 || loc.column < 0) continue;
|
|
90
|
+
if (candidates.some((c) => c.line === loc.line && c.column === loc.column)) continue;
|
|
91
|
+
candidates.push(loc);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return candidates;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatLocs(locs: SourceLoc[]): string {
|
|
98
|
+
return locs.map((loc) => `${loc.line}:${loc.column}`).join(',');
|
|
99
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defaultVocabulary, onPageParts } from './labels';
|
|
3
|
+
|
|
4
|
+
describe('onPageParts', () => {
|
|
5
|
+
it('splits the page suffix around the page number', () => {
|
|
6
|
+
expect(onPageParts(defaultVocabulary)).toEqual([' (p. ', ')']);
|
|
7
|
+
expect(onPageParts({ ...defaultVocabulary, onPage: '(第 {page} 頁)' })).toEqual([
|
|
8
|
+
'(第 ',
|
|
9
|
+
' 頁)',
|
|
10
|
+
]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/* 沒有 {page} 的後綴要原樣印出,不能把頁碼黏在編號後面變成「Table 15」。 */
|
|
14
|
+
it('has nothing to split when the suffix names no page', () => {
|
|
15
|
+
expect(onPageParts({ ...defaultVocabulary, onPage: '' })).toBeNull();
|
|
16
|
+
expect(onPageParts({ ...defaultVocabulary, onPage: ' (see below)' })).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { PAGE_ATTR, PAGE_INDEX_ATTR } from './outline';
|
|
3
|
+
|
|
4
|
+
export type LabelKind = 'figure' | 'table' | 'footnote';
|
|
5
|
+
|
|
6
|
+
export type LabelEntry = {
|
|
7
|
+
/** Author-supplied id for `<Ref to>`, or a generated one. */
|
|
8
|
+
id: string;
|
|
9
|
+
kind: LabelKind;
|
|
10
|
+
/** 1-based, counted per kind across the whole document. */
|
|
11
|
+
number: number;
|
|
12
|
+
/** Caption text, for a list of figures. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** 1-based page the item sits on. */
|
|
15
|
+
page: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const LABEL_ATTR = 'data-od-label';
|
|
19
|
+
export const LABEL_ID_ATTR = 'data-od-label-id';
|
|
20
|
+
export const LABEL_TEXT_ATTR = 'data-od-label-text';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The words wrapped around a number. Numbering is structural; what it is
|
|
24
|
+
* *called* is a document's own business — `圖 3` and `Figure 3` are the same
|
|
25
|
+
* entry. Set it once in `meta.labels`.
|
|
26
|
+
*/
|
|
27
|
+
export type LabelVocabulary = {
|
|
28
|
+
figure: string;
|
|
29
|
+
table: string;
|
|
30
|
+
/** Heading above a page's footnotes. Empty string renders no heading. */
|
|
31
|
+
footnotes: string;
|
|
32
|
+
/** Page suffix for a cross-reference. `{page}` is replaced. */
|
|
33
|
+
onPage: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const defaultVocabulary: LabelVocabulary = {
|
|
37
|
+
figure: 'Figure',
|
|
38
|
+
table: 'Table',
|
|
39
|
+
footnotes: '',
|
|
40
|
+
onPage: ' (p. {page})',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type LabelSnapshot = {
|
|
44
|
+
entries: LabelEntry[];
|
|
45
|
+
vocabulary: LabelVocabulary;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const EMPTY: LabelSnapshot = { entries: [], vocabulary: defaultVocabulary };
|
|
49
|
+
|
|
50
|
+
const KINDS: LabelKind[] = ['figure', 'table', 'footnote'];
|
|
51
|
+
|
|
52
|
+
function isKind(value: string | null): value is LabelKind {
|
|
53
|
+
return value !== null && (KINDS as string[]).includes(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Walks rendered page frames and numbers everything labelled, in document
|
|
58
|
+
* order. Numbering is a scan for the same reason the outline is: only the
|
|
59
|
+
* rendered pages know what ended up where, and a flow section's page breaks are
|
|
60
|
+
* decided by measurement, not by the author.
|
|
61
|
+
*/
|
|
62
|
+
export function collectLabels(root: ParentNode): LabelEntry[] {
|
|
63
|
+
const entries: LabelEntry[] = [];
|
|
64
|
+
const counters: Record<LabelKind, number> = { figure: 0, table: 0, footnote: 0 };
|
|
65
|
+
const frames = Array.from(root.querySelectorAll<HTMLElement>(`[${PAGE_ATTR}]`));
|
|
66
|
+
|
|
67
|
+
frames.forEach((frame, fallbackIndex) => {
|
|
68
|
+
const declared = Number(frame.getAttribute(PAGE_INDEX_ATTR));
|
|
69
|
+
const page = (Number.isFinite(declared) ? declared : fallbackIndex) + 1;
|
|
70
|
+
|
|
71
|
+
for (const el of Array.from(frame.querySelectorAll<HTMLElement>(`[${LABEL_ATTR}]`))) {
|
|
72
|
+
const kind = el.getAttribute(LABEL_ATTR);
|
|
73
|
+
if (!isKind(kind)) continue;
|
|
74
|
+
const id = el.getAttribute(LABEL_ID_ATTR);
|
|
75
|
+
if (!id) continue;
|
|
76
|
+
// A footnote's marker and the note at the foot of the page carry the same
|
|
77
|
+
// id; the marker is what fixes its position in the sequence.
|
|
78
|
+
if (entries.some((entry) => entry.id === id)) continue;
|
|
79
|
+
counters[kind] += 1;
|
|
80
|
+
entries.push({
|
|
81
|
+
id,
|
|
82
|
+
kind,
|
|
83
|
+
number: counters[kind],
|
|
84
|
+
text: (el.getAttribute(LABEL_TEXT_ATTR) ?? el.textContent ?? '')
|
|
85
|
+
.replace(/\s+/g, ' ')
|
|
86
|
+
.trim(),
|
|
87
|
+
page,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return entries;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Shared through globalThis for the same reason as the outline store: the
|
|
96
|
+
// viewer writes from the source copy of this module while a document's
|
|
97
|
+
// `<Figure>` reads from the published bundle.
|
|
98
|
+
const GLOBAL_KEY = '__mosage_labels_store__';
|
|
99
|
+
type LabelStore = { snapshot: LabelSnapshot; listeners: Set<() => void> };
|
|
100
|
+
type GlobalWithStore = typeof globalThis & { [GLOBAL_KEY]?: LabelStore };
|
|
101
|
+
const g = globalThis as GlobalWithStore;
|
|
102
|
+
if (!g[GLOBAL_KEY]) {
|
|
103
|
+
g[GLOBAL_KEY] = { snapshot: EMPTY, listeners: new Set() };
|
|
104
|
+
}
|
|
105
|
+
const store = g[GLOBAL_KEY];
|
|
106
|
+
|
|
107
|
+
function sameEntries(a: LabelEntry[], b: LabelEntry[]): boolean {
|
|
108
|
+
if (a.length !== b.length) return false;
|
|
109
|
+
return a.every((entry, i) => {
|
|
110
|
+
const other = b[i];
|
|
111
|
+
return (
|
|
112
|
+
other !== undefined &&
|
|
113
|
+
entry.id === other.id &&
|
|
114
|
+
entry.kind === other.kind &&
|
|
115
|
+
entry.number === other.number &&
|
|
116
|
+
entry.text === other.text &&
|
|
117
|
+
entry.page === other.page
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sameVocabulary(a: LabelVocabulary, b: LabelVocabulary): boolean {
|
|
123
|
+
return (
|
|
124
|
+
a.figure === b.figure &&
|
|
125
|
+
a.table === b.table &&
|
|
126
|
+
a.footnotes === b.footnotes &&
|
|
127
|
+
a.onPage === b.onPage
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function setLabels(entries: LabelEntry[], vocabulary?: Partial<LabelVocabulary>): void {
|
|
132
|
+
const next: LabelSnapshot = {
|
|
133
|
+
entries,
|
|
134
|
+
vocabulary: { ...defaultVocabulary, ...vocabulary },
|
|
135
|
+
};
|
|
136
|
+
if (
|
|
137
|
+
sameEntries(store.snapshot.entries, next.entries) &&
|
|
138
|
+
sameVocabulary(store.snapshot.vocabulary, next.vocabulary)
|
|
139
|
+
) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
store.snapshot = next;
|
|
143
|
+
for (const listener of store.listeners) listener();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getLabels(): LabelSnapshot {
|
|
147
|
+
return store.snapshot;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function subscribe(listener: () => void): () => void {
|
|
151
|
+
store.listeners.add(listener);
|
|
152
|
+
return () => store.listeners.delete(listener);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function useLabelSnapshot(): LabelSnapshot {
|
|
156
|
+
return useSyncExternalStore(subscribe, getLabels, getLabels);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The document's numbered items of one kind, in order. Empty on the first render pass. */
|
|
160
|
+
export function useDocLabels(kind: LabelKind): LabelEntry[] {
|
|
161
|
+
return useLabelSnapshot().entries.filter((entry) => entry.kind === kind);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One numbered item by id, once the scan has run. */
|
|
165
|
+
export function useDocLabel(id: string): LabelEntry | null {
|
|
166
|
+
return useLabelSnapshot().entries.find((entry) => entry.id === id) ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function useLabelVocabulary(): LabelVocabulary {
|
|
170
|
+
return useLabelSnapshot().vocabulary;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The words either side of the page number in a cross-reference's page suffix,
|
|
175
|
+
* or null when the suffix names no page — it is then printed as written.
|
|
176
|
+
*/
|
|
177
|
+
export function onPageParts(vocabulary: LabelVocabulary): [before: string, after: string] | null {
|
|
178
|
+
const at = vocabulary.onPage.indexOf('{page}');
|
|
179
|
+
if (at < 0) return null;
|
|
180
|
+
return [vocabulary.onPage.slice(0, at), vocabulary.onPage.slice(at + '{page}'.length)];
|
|
181
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
export type OutlineEntry = {
|
|
4
|
+
id: string;
|
|
5
|
+
text: string;
|
|
6
|
+
level: number;
|
|
7
|
+
/** 1-based page the heading sits on. */
|
|
8
|
+
page: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const HEADING_SELECTOR = 'h1, h2, h3, [data-od-heading]';
|
|
12
|
+
|
|
13
|
+
export const PAGE_ATTR = 'data-od-page';
|
|
14
|
+
export const PAGE_INDEX_ATTR = 'data-od-page-index';
|
|
15
|
+
/** A contents list; headings inside one are never part of the outline. */
|
|
16
|
+
export const TOC_ATTR = 'data-od-toc';
|
|
17
|
+
|
|
18
|
+
export function levelOf(el: Element): number {
|
|
19
|
+
const declared = el.getAttribute('data-od-level');
|
|
20
|
+
if (declared) {
|
|
21
|
+
const n = Number(declared);
|
|
22
|
+
if (Number.isFinite(n)) return Math.min(3, Math.max(1, Math.round(n)));
|
|
23
|
+
}
|
|
24
|
+
const tag = el.tagName.toLowerCase();
|
|
25
|
+
if (tag === 'h1') return 1;
|
|
26
|
+
if (tag === 'h2') return 2;
|
|
27
|
+
if (tag === 'h3') return 3;
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function textOf(el: Element): string {
|
|
32
|
+
const labelled = el.getAttribute('data-od-heading');
|
|
33
|
+
if (labelled && labelled !== 'true' && labelled.trim() !== '') return labelled.trim();
|
|
34
|
+
return (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The level a heading has in the outline, or null when it is kept out of it. */
|
|
38
|
+
export function outlineLevelOf(el: Element): number | null {
|
|
39
|
+
if (el.closest(`[${TOC_ATTR}]`)) return null;
|
|
40
|
+
if (el.getAttribute('data-od-outline') === 'skip') return null;
|
|
41
|
+
return levelOf(el);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Walks rendered page frames and returns their headings in document order.
|
|
46
|
+
* Headings inside a rendered table of contents are skipped so a TOC never
|
|
47
|
+
* lists itself, and every collected heading gets a stable `id` so the sidebar
|
|
48
|
+
* and in-document TOC can anchor to it.
|
|
49
|
+
*/
|
|
50
|
+
export function collectOutline(root: ParentNode): OutlineEntry[] {
|
|
51
|
+
const entries: OutlineEntry[] = [];
|
|
52
|
+
const frames = Array.from(root.querySelectorAll<HTMLElement>(`[${PAGE_ATTR}]`));
|
|
53
|
+
frames.forEach((frame, fallbackIndex) => {
|
|
54
|
+
const declared = Number(frame.getAttribute(PAGE_INDEX_ATTR));
|
|
55
|
+
const page = (Number.isFinite(declared) ? declared : fallbackIndex) + 1;
|
|
56
|
+
const headings = Array.from(frame.querySelectorAll<HTMLElement>(HEADING_SELECTOR));
|
|
57
|
+
for (const el of headings) {
|
|
58
|
+
const level = outlineLevelOf(el);
|
|
59
|
+
if (level === null) continue;
|
|
60
|
+
const text = textOf(el);
|
|
61
|
+
if (!text) continue;
|
|
62
|
+
const id = el.id || `od-h-${entries.length + 1}`;
|
|
63
|
+
if (!el.id) el.id = id;
|
|
64
|
+
entries.push({ id, text, level, page });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Shared through globalThis for the same reason as the page context: the
|
|
71
|
+
// viewer writes the outline from the source copy of this module, while a
|
|
72
|
+
// document's `<TableOfContents>` reads it from the published bundle.
|
|
73
|
+
const GLOBAL_KEY = '__mosage_outline_store__';
|
|
74
|
+
type OutlineStore = { snapshot: OutlineEntry[]; listeners: Set<() => void> };
|
|
75
|
+
type GlobalWithStore = typeof globalThis & { [GLOBAL_KEY]?: OutlineStore };
|
|
76
|
+
const g = globalThis as GlobalWithStore;
|
|
77
|
+
if (!g[GLOBAL_KEY]) {
|
|
78
|
+
g[GLOBAL_KEY] = { snapshot: [], listeners: new Set() };
|
|
79
|
+
}
|
|
80
|
+
const store = g[GLOBAL_KEY];
|
|
81
|
+
|
|
82
|
+
function sameOutline(a: OutlineEntry[], b: OutlineEntry[]): boolean {
|
|
83
|
+
if (a.length !== b.length) return false;
|
|
84
|
+
return a.every((entry, i) => {
|
|
85
|
+
const other = b[i];
|
|
86
|
+
return (
|
|
87
|
+
other !== undefined &&
|
|
88
|
+
entry.id === other.id &&
|
|
89
|
+
entry.text === other.text &&
|
|
90
|
+
entry.level === other.level &&
|
|
91
|
+
entry.page === other.page
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setOutline(entries: OutlineEntry[]): void {
|
|
97
|
+
if (sameOutline(store.snapshot, entries)) return;
|
|
98
|
+
store.snapshot = entries;
|
|
99
|
+
for (const listener of store.listeners) listener();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getOutline(): OutlineEntry[] {
|
|
103
|
+
return store.snapshot;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function subscribe(listener: () => void): () => void {
|
|
107
|
+
store.listeners.add(listener);
|
|
108
|
+
return () => store.listeners.delete(listener);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The document's headings, filled in after the pages render. Empty on the first
|
|
113
|
+
* pass — a `<TableOfContents>` renders its rows on the second, once the scan
|
|
114
|
+
* has run.
|
|
115
|
+
*/
|
|
116
|
+
export function useDocOutline(): OutlineEntry[] {
|
|
117
|
+
return useSyncExternalStore(subscribe, getOutline, getOutline);
|
|
118
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Context, createContext, type ReactNode, useContext, useMemo } from 'react';
|
|
2
|
+
import { FootnoteCollector } from '../components/footnote';
|
|
3
|
+
|
|
4
|
+
type PageContextValue = { index: number; total: number };
|
|
5
|
+
|
|
6
|
+
// Stored on globalThis so the dev (src) and published (dist) copies of this
|
|
7
|
+
// module share one context instance — a document imports `mosage`
|
|
8
|
+
// (dist) while the viewer imports the source, so without this the provider
|
|
9
|
+
// writes to one context and the hook reads from another.
|
|
10
|
+
const GLOBAL_KEY = '__mosage_page_context__';
|
|
11
|
+
type GlobalWithCtx = typeof globalThis & {
|
|
12
|
+
[GLOBAL_KEY]?: Context<PageContextValue | null>;
|
|
13
|
+
};
|
|
14
|
+
const g = globalThis as GlobalWithCtx;
|
|
15
|
+
if (!g[GLOBAL_KEY]) {
|
|
16
|
+
g[GLOBAL_KEY] = createContext<PageContextValue | null>(null);
|
|
17
|
+
}
|
|
18
|
+
const PageContext = g[GLOBAL_KEY];
|
|
19
|
+
|
|
20
|
+
export function DocPageProvider({
|
|
21
|
+
index,
|
|
22
|
+
total,
|
|
23
|
+
children,
|
|
24
|
+
}: PageContextValue & { children?: ReactNode }) {
|
|
25
|
+
const value = useMemo(() => ({ index, total }), [index, total]);
|
|
26
|
+
return (
|
|
27
|
+
<PageContext.Provider value={value}>
|
|
28
|
+
<FootnoteCollector>{children}</FootnoteCollector>
|
|
29
|
+
</PageContext.Provider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 1-based page number of the page currently rendering. `0` outside a page. */
|
|
34
|
+
export function useDocPageNumber(): number {
|
|
35
|
+
const ctx = useContext(PageContext);
|
|
36
|
+
return ctx ? ctx.index + 1 : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Total page count of the document being rendered. `0` outside a page. */
|
|
40
|
+
export function useDocPageCount(): number {
|
|
41
|
+
const ctx = useContext(PageContext);
|
|
42
|
+
return ctx ? ctx.total : 0;
|
|
43
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page ranges, where off-by-one lives.
|
|
3
|
+
*
|
|
4
|
+
* The reader types "1" and means the first page; the array wants index 0. Every
|
|
5
|
+
* assertion here is about that seam, plus the two answers that must stay
|
|
6
|
+
* distinct: "nothing you typed names a page" and "you selected no pages". Only
|
|
7
|
+
* one of them should ever start a download, and neither should download
|
|
8
|
+
* everything by accident.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { describeSelection, parseRange, resolveSelection } from './page-range';
|
|
13
|
+
|
|
14
|
+
describe('parseRange', () => {
|
|
15
|
+
it('reads single pages, one-based', () => {
|
|
16
|
+
expect(parseRange('1', 5)).toEqual([0]);
|
|
17
|
+
expect(parseRange('3', 5)).toEqual([2]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('reads spans, inclusive at both ends', () => {
|
|
21
|
+
expect(parseRange('1-3', 5)).toEqual([0, 1, 2]);
|
|
22
|
+
expect(parseRange('4-5', 5)).toEqual([3, 4]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reads a mixed list, sorted and deduplicated', () => {
|
|
26
|
+
expect(parseRange('5,1,2-3,1', 9)).toEqual([0, 1, 2, 4]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/* A range typed backwards is a range, not a mistake worth refusing. */
|
|
30
|
+
it('accepts a span written the wrong way round', () => {
|
|
31
|
+
expect(parseRange('5-2', 9)).toEqual([1, 2, 3, 4]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/* 中文鍵盤打出來的全形數字和破折號,使用者不會察覺自己打的是全形。 */
|
|
35
|
+
it('accepts full-width digits and dashes', () => {
|
|
36
|
+
expect(parseRange('1-3', 5)).toEqual([0, 1, 2]);
|
|
37
|
+
expect(parseRange('2,4', 5)).toEqual([1, 3]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('clips to the document rather than inventing pages', () => {
|
|
41
|
+
expect(parseRange('3-99', 5)).toEqual([2, 3, 4]);
|
|
42
|
+
expect(parseRange('0-2', 5)).toEqual([0, 1]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* The important negative: nothing usable must be null, never an empty array.
|
|
47
|
+
* An empty array flowing on would start a download of no pages.
|
|
48
|
+
*/
|
|
49
|
+
it('returns null when nothing names a real page', () => {
|
|
50
|
+
expect(parseRange('', 5)).toBeNull();
|
|
51
|
+
expect(parseRange(' ', 5)).toBeNull();
|
|
52
|
+
expect(parseRange('abc', 5)).toBeNull();
|
|
53
|
+
expect(parseRange('9', 5)).toBeNull();
|
|
54
|
+
expect(parseRange('-', 5)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('resolveSelection', () => {
|
|
59
|
+
it('all covers the document', () => {
|
|
60
|
+
expect(resolveSelection({ kind: 'all' }, 3, 2)).toEqual([0, 1, 2]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('current is the page being read, one-based in and zero-based out', () => {
|
|
64
|
+
expect(resolveSelection({ kind: 'current' }, 5, 1)).toEqual([0]);
|
|
65
|
+
expect(resolveSelection({ kind: 'current' }, 5, 4)).toEqual([3]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/* currentPage can lag the document while it reflows; it must not index past. */
|
|
69
|
+
it('clamps a current page outside the document', () => {
|
|
70
|
+
expect(resolveSelection({ kind: 'current' }, 3, 99)).toEqual([2]);
|
|
71
|
+
expect(resolveSelection({ kind: 'current' }, 3, 0)).toEqual([0]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('has nothing to give for an empty document', () => {
|
|
75
|
+
expect(resolveSelection({ kind: 'all' }, 0, 1)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('describeSelection', () => {
|
|
80
|
+
it('counts what the button is about to do', () => {
|
|
81
|
+
expect(describeSelection({ kind: 'all' }, 4, 1)).toEqual({ count: 4, valid: true });
|
|
82
|
+
expect(describeSelection({ kind: 'current' }, 4, 2)).toEqual({ count: 1, valid: true });
|
|
83
|
+
expect(describeSelection({ kind: 'custom', text: '2-3' }, 4, 1)).toEqual({
|
|
84
|
+
count: 2,
|
|
85
|
+
valid: true,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reports an unreadable range as invalid, not as zero pages', () => {
|
|
90
|
+
expect(describeSelection({ kind: 'custom', text: 'nope' }, 4, 1)).toEqual({
|
|
91
|
+
count: 0,
|
|
92
|
+
valid: false,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pages a download covers.
|
|
3
|
+
*
|
|
4
|
+
* The custom field takes what a print dialog takes — `2`, `1-3`, `2,5,7-9` —
|
|
5
|
+
* because that is the notation people already have in their fingers. Everything
|
|
6
|
+
* here is 1-based on the way in and 0-based on the way out, which is where this
|
|
7
|
+
* kind of code usually goes wrong: the reader says "page 1" and the array
|
|
8
|
+
* wants index 0.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type PageSelection =
|
|
12
|
+
| { kind: 'all' }
|
|
13
|
+
| { kind: 'current' }
|
|
14
|
+
| { kind: 'custom'; text: string };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Zero-based indices, ascending, no duplicates.
|
|
18
|
+
*
|
|
19
|
+
* An unparseable or empty custom range returns null rather than an empty list:
|
|
20
|
+
* "I could not read that" and "you asked for no pages" are different answers,
|
|
21
|
+
* and only one of them should be allowed to start a download.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveSelection(
|
|
24
|
+
selection: PageSelection,
|
|
25
|
+
total: number,
|
|
26
|
+
currentPage: number,
|
|
27
|
+
): number[] | null {
|
|
28
|
+
if (total <= 0) return null;
|
|
29
|
+
if (selection.kind === 'all') return Array.from({ length: total }, (_, i) => i);
|
|
30
|
+
if (selection.kind === 'current') {
|
|
31
|
+
const index = clamp(currentPage, 1, total) - 1;
|
|
32
|
+
return [index];
|
|
33
|
+
}
|
|
34
|
+
return parseRange(selection.text, total);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `2,5,7-9` → [1,4,6,7,8]. Null when nothing in the text names a real page. */
|
|
38
|
+
export function parseRange(text: string, total: number): number[] | null {
|
|
39
|
+
if (text.trim() === '') return null;
|
|
40
|
+
|
|
41
|
+
const wanted = new Set<number>();
|
|
42
|
+
let sawSomething = false;
|
|
43
|
+
|
|
44
|
+
for (const chunk of text.split(/[,,\s]+/)) {
|
|
45
|
+
if (chunk === '') continue;
|
|
46
|
+
/* Full-width digits and dashes: a Chinese keyboard produces them without
|
|
47
|
+
the typist noticing, and rejecting them reads as the field being broken. */
|
|
48
|
+
const plain = chunk
|
|
49
|
+
.replace(/[0-9]/g, (d) => String(d.charCodeAt(0) - 0xff10))
|
|
50
|
+
.replace(/[–—ー-]/g, '-');
|
|
51
|
+
|
|
52
|
+
const span = /^(\d+)-(\d+)$/.exec(plain);
|
|
53
|
+
if (span) {
|
|
54
|
+
const from = Number(span[1]);
|
|
55
|
+
const to = Number(span[2]);
|
|
56
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) continue;
|
|
57
|
+
/* `5-2` is a range someone typed backwards, not an empty one. */
|
|
58
|
+
const [lo, hi] = from <= to ? [from, to] : [to, from];
|
|
59
|
+
for (let page = Math.max(1, lo); page <= Math.min(total, hi); page++) {
|
|
60
|
+
wanted.add(page - 1);
|
|
61
|
+
sawSomething = true;
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (/^\d+$/.test(plain)) {
|
|
67
|
+
const page = Number(plain);
|
|
68
|
+
if (page >= 1 && page <= total) {
|
|
69
|
+
wanted.add(page - 1);
|
|
70
|
+
sawSomething = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!sawSomething || wanted.size === 0) return null;
|
|
76
|
+
return [...wanted].sort((a, b) => a - b);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function describeSelection(
|
|
80
|
+
selection: PageSelection,
|
|
81
|
+
total: number,
|
|
82
|
+
currentPage: number,
|
|
83
|
+
): { count: number; valid: boolean } {
|
|
84
|
+
const pages = resolveSelection(selection, total, currentPage);
|
|
85
|
+
return { count: pages?.length ?? 0, valid: pages !== null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clamp(value: number, min: number, max: number): number {
|
|
89
|
+
return Math.min(max, Math.max(min, value));
|
|
90
|
+
}
|