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,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type BlockMetrics, paginateBlocks } from './flow.ts';
|
|
3
|
+
|
|
4
|
+
const b = (height: number, extra: Partial<BlockMetrics> = {}): BlockMetrics => ({
|
|
5
|
+
height,
|
|
6
|
+
...extra,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('paginateBlocks', () => {
|
|
10
|
+
it('fills a page before starting the next', () => {
|
|
11
|
+
const { pages } = paginateBlocks([b(300), b(300), b(300), b(300)], 1000);
|
|
12
|
+
expect(pages).toEqual([[0, 1, 2], [3]]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps everything on one page when it fits', () => {
|
|
16
|
+
const { pages } = paginateBlocks([b(100), b(100)], 1000);
|
|
17
|
+
expect(pages).toEqual([[0, 1]]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('never leaves a heading as the last block on a page', () => {
|
|
21
|
+
// The heading fits at the bottom, but its body does not — both move.
|
|
22
|
+
const { pages } = paginateBlocks([b(400), b(400), b(60, { keepWithNext: true }), b(300)], 1000);
|
|
23
|
+
expect(pages).toEqual([
|
|
24
|
+
[0, 1],
|
|
25
|
+
[2, 3],
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('moves a run of stacked headings together', () => {
|
|
30
|
+
const { pages } = paginateBlocks(
|
|
31
|
+
[b(800), b(50, { keepWithNext: true }), b(50, { keepWithNext: true }), b(300)],
|
|
32
|
+
1000,
|
|
33
|
+
);
|
|
34
|
+
expect(pages).toEqual([[0], [1, 2, 3]]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('pulls a figure along with its caption', () => {
|
|
38
|
+
const { pages } = paginateBlocks([b(300), b(600), b(120, { keepWithPrevious: true })], 1000);
|
|
39
|
+
expect(pages).toEqual([[0], [1, 2]]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('honours an explicit page break', () => {
|
|
43
|
+
const { pages } = paginateBlocks([b(100), b(100, { breakBefore: true }), b(100)], 1000);
|
|
44
|
+
expect(pages).toEqual([[0], [1, 2]]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('gives an oversized block its own page and reports it', () => {
|
|
48
|
+
const { pages, overflowing } = paginateBlocks([b(200), b(1400), b(200)], 1000);
|
|
49
|
+
expect(pages).toEqual([[0], [1], [2]]);
|
|
50
|
+
expect(overflowing).toEqual([1]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not strand a page when every block wants to keep with the next', () => {
|
|
54
|
+
const { pages } = paginateBlocks(
|
|
55
|
+
[b(600, { keepWithNext: true }), b(600, { keepWithNext: true }), b(600)],
|
|
56
|
+
1000,
|
|
57
|
+
);
|
|
58
|
+
expect(pages).toEqual([[0], [1], [2]]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns no pages for no blocks', () => {
|
|
62
|
+
expect(paginateBlocks([], 1000)).toEqual({ pages: [], overflowing: [] });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('paginateBlocks with footnotes', () => {
|
|
67
|
+
const block = (height: number, footnoteHeight = 0): BlockMetrics => ({ height, footnoteHeight });
|
|
68
|
+
|
|
69
|
+
it('gives a page less body room when its blocks carry notes', () => {
|
|
70
|
+
// Three 300px blocks fit 1000px on their own.
|
|
71
|
+
expect(paginateBlocks([block(300), block(300), block(300)], 1000).pages).toEqual([[0, 1, 2]]);
|
|
72
|
+
|
|
73
|
+
// Add 100px of notes to the first block plus 20px of area chrome and the
|
|
74
|
+
// third no longer fits: notes print on the page their marker landed on.
|
|
75
|
+
const withNotes = paginateBlocks([block(300, 100), block(300), block(300)], 1000, {
|
|
76
|
+
footnoteOverhead: 20,
|
|
77
|
+
});
|
|
78
|
+
expect(withNotes.pages).toEqual([[0, 1], [2]]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('charges the area chrome once per page, not once per note', () => {
|
|
82
|
+
const blocks = [block(300, 50), block(300, 50), block(300)];
|
|
83
|
+
// 900 body + 100 notes + 20 chrome = 1020 > 1000.
|
|
84
|
+
expect(paginateBlocks(blocks, 1000, { footnoteOverhead: 20 }).pages).toEqual([[0, 1], [2]]);
|
|
85
|
+
// Without the chrome it is exactly 1000 and all three stay together.
|
|
86
|
+
expect(paginateBlocks(blocks, 1000, { footnoteOverhead: 0 }).pages).toEqual([[0, 1, 2]]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('recomputes the notes budget for blocks that move to the next page', () => {
|
|
90
|
+
const blocks: BlockMetrics[] = [
|
|
91
|
+
{ height: 300, footnoteHeight: 100 },
|
|
92
|
+
{ height: 100, keepWithNext: true },
|
|
93
|
+
{ height: 700 },
|
|
94
|
+
];
|
|
95
|
+
const result = paginateBlocks(blocks, 1000, { footnoteOverhead: 20 });
|
|
96
|
+
// The heading travels with the block it introduces, and its page's budget
|
|
97
|
+
// is recomputed without the first block's notes.
|
|
98
|
+
expect(result.pages).toEqual([[0], [1, 2]]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('flags a block whose own notes make it taller than a page', () => {
|
|
102
|
+
const result = paginateBlocks([block(900, 200)], 1000, { footnoteOverhead: 20 });
|
|
103
|
+
expect(result.overflowing).toEqual([0]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('leaves documents without notes packing exactly as before', () => {
|
|
107
|
+
const blocks = [block(400), block(400), block(400)];
|
|
108
|
+
expect(paginateBlocks(blocks, 1000, { footnoteOverhead: 20 }).pages).toEqual([[0, 1], [2]]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Children, type ComponentType, Fragment, isValidElement, type ReactNode } from 'react';
|
|
2
|
+
import type { DocEntry, FlowSection } from './sdk';
|
|
3
|
+
|
|
4
|
+
export type { DocEntry, FlowSection };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Flattens fragments so `flow(<>…</>)` yields one block per element the author
|
|
8
|
+
* wrote, not a single block containing the whole fragment.
|
|
9
|
+
*/
|
|
10
|
+
function toBlocks(node: ReactNode): ReactNode[] {
|
|
11
|
+
const out: ReactNode[] = [];
|
|
12
|
+
for (const child of Children.toArray(node)) {
|
|
13
|
+
if (!isValidElement(child)) continue;
|
|
14
|
+
if (child.type === Fragment) {
|
|
15
|
+
out.push(...toBlocks((child.props as { children?: ReactNode }).children));
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
out.push(child);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wraps continuous content so the framework paginates it, instead of the author
|
|
25
|
+
* hand-splitting sections into fixed pages. Each direct child is one atomic
|
|
26
|
+
* block — a heading, a paragraph, a table — that never straddles a page break.
|
|
27
|
+
*/
|
|
28
|
+
export function flow(
|
|
29
|
+
children: ReactNode,
|
|
30
|
+
opts: { footer?: ComponentType; padding?: number } = {},
|
|
31
|
+
): FlowSection {
|
|
32
|
+
return { __odFlow: true, blocks: toBlocks(children), ...opts };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isFlowSection(entry: DocEntry): entry is FlowSection {
|
|
36
|
+
return typeof entry === 'object' && entry !== null && (entry as FlowSection).__odFlow === true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type BlockMetrics = {
|
|
40
|
+
height: number;
|
|
41
|
+
/** Height this block's footnotes add to the foot of whatever page it lands on. */
|
|
42
|
+
footnoteHeight?: number;
|
|
43
|
+
/** A heading must not be the last block on a page. */
|
|
44
|
+
keepWithNext?: boolean;
|
|
45
|
+
/** A caption must not open a page without the figure it belongs to. */
|
|
46
|
+
keepWithPrevious?: boolean;
|
|
47
|
+
/** Always start a new page before this block. */
|
|
48
|
+
breakBefore?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type PaginationResult = {
|
|
52
|
+
/** Block indices per page, in order. */
|
|
53
|
+
pages: number[][];
|
|
54
|
+
/** Blocks taller than one page — they stay whole and overflow. */
|
|
55
|
+
overflowing: number[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type PaginateOptions = {
|
|
59
|
+
/** Rule and padding a page's footnote area costs before its first note. */
|
|
60
|
+
footnoteOverhead?: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Greedy top-to-bottom packing: fill a page until the next block would cross
|
|
65
|
+
* the bottom edge, then push that block — plus anything glued to it — onto the
|
|
66
|
+
* next page. Blocks are atomic; nothing is split mid-block.
|
|
67
|
+
*
|
|
68
|
+
* Footnotes are packed from the same budget: a block that carries notes brings
|
|
69
|
+
* their height with it, because those notes print at the foot of whichever page
|
|
70
|
+
* the block lands on.
|
|
71
|
+
*/
|
|
72
|
+
export function paginateBlocks(
|
|
73
|
+
blocks: BlockMetrics[],
|
|
74
|
+
available: number,
|
|
75
|
+
opts: PaginateOptions = {},
|
|
76
|
+
): PaginationResult {
|
|
77
|
+
const overhead = opts.footnoteOverhead ?? 0;
|
|
78
|
+
const pages: number[][] = [];
|
|
79
|
+
const overflowing: number[] = [];
|
|
80
|
+
let current: number[] = [];
|
|
81
|
+
let used = 0;
|
|
82
|
+
let notes = 0;
|
|
83
|
+
|
|
84
|
+
const heightOf = (indices: number[]) =>
|
|
85
|
+
indices.reduce((sum, index) => sum + (blocks[index]?.height ?? 0), 0);
|
|
86
|
+
const notesOf = (indices: number[]) =>
|
|
87
|
+
indices.reduce((sum, index) => sum + (blocks[index]?.footnoteHeight ?? 0), 0);
|
|
88
|
+
/** A page with no notes pays nothing; the first note also pays for the rule. */
|
|
89
|
+
const reserve = (total: number) => (total > 0 ? total + overhead : 0);
|
|
90
|
+
|
|
91
|
+
const closePage = () => {
|
|
92
|
+
if (current.length === 0) return;
|
|
93
|
+
pages.push(current);
|
|
94
|
+
current = [];
|
|
95
|
+
used = 0;
|
|
96
|
+
notes = 0;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
blocks.forEach((block, index) => {
|
|
100
|
+
const own = block.footnoteHeight ?? 0;
|
|
101
|
+
if (block.height + reserve(own) > available) overflowing.push(index);
|
|
102
|
+
|
|
103
|
+
if (block.breakBefore) closePage();
|
|
104
|
+
|
|
105
|
+
if (current.length > 0 && used + block.height + reserve(notes + own) > available) {
|
|
106
|
+
// Everything glued to this block travels with it: trailing headings
|
|
107
|
+
// (keep-with-next) and, when this block is a caption, its figure.
|
|
108
|
+
let splitAt = current.length;
|
|
109
|
+
while (splitAt > 0 && blocks[current[splitAt - 1]]?.keepWithNext) splitAt--;
|
|
110
|
+
if (block.keepWithPrevious && splitAt === current.length) splitAt--;
|
|
111
|
+
|
|
112
|
+
if (splitAt > 0 && splitAt < current.length) {
|
|
113
|
+
const moved = current.slice(splitAt);
|
|
114
|
+
pages.push(current.slice(0, splitAt));
|
|
115
|
+
current = moved;
|
|
116
|
+
used = heightOf(moved);
|
|
117
|
+
notes = notesOf(moved);
|
|
118
|
+
} else {
|
|
119
|
+
closePage();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
current.push(index);
|
|
124
|
+
used += block.height;
|
|
125
|
+
notes += own;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
closePage();
|
|
129
|
+
return { pages, overflowing };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Blocks of the first flow section, for thumbnails that skip measurement. */
|
|
133
|
+
export function firstFlowBlocks(entries: DocEntry[], limit = 12): ReactNode[] {
|
|
134
|
+
const first = entries.find(isFlowSection);
|
|
135
|
+
return first ? first.blocks.slice(0, limit) : [];
|
|
136
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import buildManifest from 'virtual:mosage/folders';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import type { Folder, FolderIcon, FoldersManifest } from './sdk';
|
|
4
|
+
|
|
5
|
+
const EMPTY: FoldersManifest = { folders: [], assignments: {} };
|
|
6
|
+
|
|
7
|
+
async function getManifest(): Promise<FoldersManifest> {
|
|
8
|
+
// In dev the manifest is mutable, so read it live from the API — the sidebar
|
|
9
|
+
// must reflect edits immediately. A static build has no server, so it falls
|
|
10
|
+
// back to the snapshot the virtual module baked in from docs/.folders.json.
|
|
11
|
+
if (import.meta.env.DEV) {
|
|
12
|
+
const res = await fetch('/__folders');
|
|
13
|
+
if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
|
|
14
|
+
const raw = (await res.json()) as Partial<FoldersManifest>;
|
|
15
|
+
return { folders: raw.folders ?? [], assignments: raw.assignments ?? {} };
|
|
16
|
+
}
|
|
17
|
+
return { folders: buildManifest.folders ?? [], assignments: buildManifest.assignments ?? {} };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function expectOk(res: Response, label: string): Promise<void> {
|
|
21
|
+
if (res.ok) return;
|
|
22
|
+
let detail = `${res.status}`;
|
|
23
|
+
try {
|
|
24
|
+
const body = (await res.json()) as { error?: string };
|
|
25
|
+
if (body.error) detail = body.error;
|
|
26
|
+
} catch {}
|
|
27
|
+
throw new Error(`${label}: ${detail}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const jsonInit = (method: string, body: unknown): RequestInit => ({
|
|
31
|
+
method,
|
|
32
|
+
headers: { 'content-type': 'application/json' },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type UseFoldersResult = {
|
|
37
|
+
manifest: FoldersManifest;
|
|
38
|
+
loading: boolean;
|
|
39
|
+
create: (name: string, icon: FolderIcon) => Promise<Folder>;
|
|
40
|
+
update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
|
|
41
|
+
remove: (id: string) => Promise<void>;
|
|
42
|
+
reorder: (ids: string[]) => Promise<void>;
|
|
43
|
+
assign: (docId: string, folderId: string | null) => Promise<void>;
|
|
44
|
+
renameDoc: (docId: string, title: string) => Promise<void>;
|
|
45
|
+
duplicateDoc: (docId: string, newId?: string) => Promise<string>;
|
|
46
|
+
deleteDoc: (docId: string) => Promise<void>;
|
|
47
|
+
refresh: () => Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function useFolders(): UseFoldersResult {
|
|
51
|
+
const [manifest, setManifest] = useState<FoldersManifest>(EMPTY);
|
|
52
|
+
const [loading, setLoading] = useState(true);
|
|
53
|
+
|
|
54
|
+
const refresh = useCallback(async () => {
|
|
55
|
+
setManifest(await getManifest());
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
getManifest()
|
|
61
|
+
.then((m) => {
|
|
62
|
+
if (cancelled) return;
|
|
63
|
+
setManifest(m);
|
|
64
|
+
setLoading(false);
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
if (!cancelled) setLoading(false);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
cancelled = true;
|
|
71
|
+
};
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!import.meta.hot) return;
|
|
76
|
+
const handler = () => {
|
|
77
|
+
refresh().catch(() => {});
|
|
78
|
+
};
|
|
79
|
+
import.meta.hot.on('mosage:files-changed', handler);
|
|
80
|
+
return () => {
|
|
81
|
+
import.meta.hot?.off('mosage:files-changed', handler);
|
|
82
|
+
};
|
|
83
|
+
}, [refresh]);
|
|
84
|
+
|
|
85
|
+
const create = useCallback(
|
|
86
|
+
async (name: string, icon: FolderIcon) => {
|
|
87
|
+
const res = await fetch('/__folders', jsonInit('POST', { name, icon }));
|
|
88
|
+
await expectOk(res, 'Create folder failed');
|
|
89
|
+
const folder = (await res.json()) as Folder;
|
|
90
|
+
await refresh();
|
|
91
|
+
return folder;
|
|
92
|
+
},
|
|
93
|
+
[refresh],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const update = useCallback(
|
|
97
|
+
async (id: string, patch: { name?: string; icon?: FolderIcon }) => {
|
|
98
|
+
await expectOk(
|
|
99
|
+
await fetch(`/__folders/${id}`, jsonInit('PATCH', patch)),
|
|
100
|
+
'Update folder failed',
|
|
101
|
+
);
|
|
102
|
+
await refresh();
|
|
103
|
+
},
|
|
104
|
+
[refresh],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const remove = useCallback(
|
|
108
|
+
async (id: string) => {
|
|
109
|
+
await expectOk(await fetch(`/__folders/${id}`, { method: 'DELETE' }), 'Delete folder failed');
|
|
110
|
+
await refresh();
|
|
111
|
+
},
|
|
112
|
+
[refresh],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const reorder = useCallback(
|
|
116
|
+
async (ids: string[]) => {
|
|
117
|
+
const prev = manifest;
|
|
118
|
+
const byId = new Map(prev.folders.map((f) => [f.id, f]));
|
|
119
|
+
const next = ids.map((id) => byId.get(id)).filter((f): f is Folder => Boolean(f));
|
|
120
|
+
if (next.length !== prev.folders.length) return;
|
|
121
|
+
setManifest({ ...prev, folders: next });
|
|
122
|
+
try {
|
|
123
|
+
await expectOk(
|
|
124
|
+
await fetch('/__folders/reorder', jsonInit('PUT', { ids })),
|
|
125
|
+
'Reorder failed',
|
|
126
|
+
);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
setManifest(prev);
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
[manifest],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const assign = useCallback(
|
|
136
|
+
async (docId: string, folderId: string | null) => {
|
|
137
|
+
await expectOk(
|
|
138
|
+
await fetch('/__folders/assign', jsonInit('PUT', { docId, folderId })),
|
|
139
|
+
'Move failed',
|
|
140
|
+
);
|
|
141
|
+
await refresh();
|
|
142
|
+
},
|
|
143
|
+
[refresh],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const renameDoc = useCallback(
|
|
147
|
+
async (docId: string, title: string) => {
|
|
148
|
+
await expectOk(
|
|
149
|
+
await fetch(`/__docs/${docId}`, jsonInit('PATCH', { title })),
|
|
150
|
+
'Rename failed',
|
|
151
|
+
);
|
|
152
|
+
await refresh();
|
|
153
|
+
},
|
|
154
|
+
[refresh],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const duplicateDoc = useCallback(
|
|
158
|
+
async (docId: string, newId?: string) => {
|
|
159
|
+
const init: RequestInit =
|
|
160
|
+
newId === undefined ? { method: 'POST' } : jsonInit('POST', { newId });
|
|
161
|
+
const res = await fetch(`/__docs/${docId}/duplicate`, init);
|
|
162
|
+
await expectOk(res, 'Duplicate failed');
|
|
163
|
+
const body = (await res.json()) as { docId?: unknown };
|
|
164
|
+
await refresh();
|
|
165
|
+
if (typeof body.docId !== 'string') throw new Error('duplicate response missing docId');
|
|
166
|
+
return body.docId;
|
|
167
|
+
},
|
|
168
|
+
[refresh],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const deleteDoc = useCallback(
|
|
172
|
+
async (docId: string) => {
|
|
173
|
+
await expectOk(await fetch(`/__docs/${docId}`, { method: 'DELETE' }), 'Delete failed');
|
|
174
|
+
await refresh();
|
|
175
|
+
},
|
|
176
|
+
[refresh],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
manifest,
|
|
181
|
+
loading,
|
|
182
|
+
create,
|
|
183
|
+
update,
|
|
184
|
+
remove,
|
|
185
|
+
reorder,
|
|
186
|
+
assign,
|
|
187
|
+
renameDoc,
|
|
188
|
+
duplicateDoc,
|
|
189
|
+
deleteDoc,
|
|
190
|
+
refresh,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createElement, isValidElement, type ReactNode } from 'react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { Footnote } from '../components/footnote';
|
|
4
|
+
import { extractBlockFootnotes, extractSectionFootnotes, notesForPage } from './footnotes';
|
|
5
|
+
|
|
6
|
+
function childrenOf(node: ReactNode): ReactNode {
|
|
7
|
+
return isValidElement(node) ? (node.props as { children?: ReactNode }).children : undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('extractBlockFootnotes', () => {
|
|
11
|
+
it('lifts a note out and leaves a marker in its place', () => {
|
|
12
|
+
const block = (
|
|
13
|
+
<p>
|
|
14
|
+
A claim
|
|
15
|
+
<Footnote>Source: Q3 billing export.</Footnote>
|
|
16
|
+
and the rest.
|
|
17
|
+
</p>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const { node, notes } = extractBlockFootnotes(block, 'fn-0-0');
|
|
21
|
+
expect(notes).toHaveLength(1);
|
|
22
|
+
expect(notes[0].id).toBe('fn-0-0-1');
|
|
23
|
+
expect(notes[0].content).toBe('Source: Q3 billing export.');
|
|
24
|
+
|
|
25
|
+
const rendered = JSON.stringify(node, (key, value) => (key === 'type' ? undefined : value));
|
|
26
|
+
expect(rendered).not.toContain('Source: Q3 billing export.');
|
|
27
|
+
expect(node).not.toBe(block);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('honours an author id so a Ref can point at the note', () => {
|
|
31
|
+
const { notes } = extractBlockFootnotes(
|
|
32
|
+
<p>
|
|
33
|
+
x<Footnote id="billing">note</Footnote>
|
|
34
|
+
</p>,
|
|
35
|
+
'fn-0-0',
|
|
36
|
+
);
|
|
37
|
+
expect(notes[0].id).toBe('billing');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('finds notes nested several elements deep', () => {
|
|
41
|
+
const { notes } = extractBlockFootnotes(
|
|
42
|
+
<div>
|
|
43
|
+
<ul>
|
|
44
|
+
<li>
|
|
45
|
+
one<Footnote>first</Footnote>
|
|
46
|
+
</li>
|
|
47
|
+
<li>
|
|
48
|
+
two<Footnote>second</Footnote>
|
|
49
|
+
</li>
|
|
50
|
+
</ul>
|
|
51
|
+
</div>,
|
|
52
|
+
'fn-1-2',
|
|
53
|
+
);
|
|
54
|
+
expect(notes.map((note) => note.content)).toEqual(['first', 'second']);
|
|
55
|
+
expect(notes.map((note) => note.id)).toEqual(['fn-1-2-1', 'fn-1-2-2']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns the block untouched when it has no footnotes', () => {
|
|
59
|
+
const block = <p>Nothing to lift.</p>;
|
|
60
|
+
const { node, notes } = extractBlockFootnotes(block, 'fn-0-0');
|
|
61
|
+
expect(node).toBe(block);
|
|
62
|
+
expect(notes).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('leaves a render-prop child alone rather than walking into a function', () => {
|
|
66
|
+
const block = createElement('div', null, (() => null) as unknown as ReactNode);
|
|
67
|
+
expect(extractBlockFootnotes(block, 'fn-0-0').node).toBe(block);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps surrounding text intact', () => {
|
|
71
|
+
const { node } = extractBlockFootnotes(
|
|
72
|
+
<p>
|
|
73
|
+
before<Footnote>note</Footnote>after
|
|
74
|
+
</p>,
|
|
75
|
+
'fn-0-0',
|
|
76
|
+
);
|
|
77
|
+
const children = childrenOf(node) as ReactNode[];
|
|
78
|
+
expect(children[0]).toBe('before');
|
|
79
|
+
expect(children[2]).toBe('after');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('extractSectionFootnotes', () => {
|
|
84
|
+
it('keeps notes index-aligned with their blocks', () => {
|
|
85
|
+
const prepared = extractSectionFootnotes(
|
|
86
|
+
[
|
|
87
|
+
<p key="a">plain</p>,
|
|
88
|
+
<p key="b">
|
|
89
|
+
x<Footnote>note</Footnote>
|
|
90
|
+
</p>,
|
|
91
|
+
],
|
|
92
|
+
0,
|
|
93
|
+
);
|
|
94
|
+
expect(prepared.notesByBlock[0]).toEqual([]);
|
|
95
|
+
expect(prepared.notesByBlock[1]).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('collects only the notes belonging to a page', () => {
|
|
99
|
+
const notesByBlock = [[{ id: 'a', content: 'A' }], [], [{ id: 'c', content: 'C' }]];
|
|
100
|
+
expect(notesForPage(notesByBlock, [1, 2]).map((note) => note.id)).toEqual(['c']);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Children, cloneElement, createElement, isValidElement, type ReactNode } from 'react';
|
|
2
|
+
import { FOOTNOTE_MARKER_FLAG, FootnoteMarker } from '../components/footnote';
|
|
3
|
+
|
|
4
|
+
export type ExtractedNote = { id: string; content: ReactNode };
|
|
5
|
+
|
|
6
|
+
export type ExtractedBlock = {
|
|
7
|
+
/** The block with each `<Footnote>` swapped for its marker. */
|
|
8
|
+
node: ReactNode;
|
|
9
|
+
notes: ExtractedNote[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Identified by a flag on the function, not by identity — core exists twice at runtime. */
|
|
13
|
+
function isFootnoteElement(node: ReactNode): boolean {
|
|
14
|
+
if (!isValidElement(node)) return false;
|
|
15
|
+
const type = node.type as unknown;
|
|
16
|
+
if (typeof type !== 'function') return false;
|
|
17
|
+
return (type as unknown as Record<string, unknown>)[FOOTNOTE_MARKER_FLAG] === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lifts the notes out of a block and leaves the markers behind.
|
|
22
|
+
*
|
|
23
|
+
* A footnote occupies space at the foot of its page, which is space the packer
|
|
24
|
+
* cannot give to body content — so the notes have to be known *before*
|
|
25
|
+
* pagination, not discovered while rendering it. Walking the block's element
|
|
26
|
+
* tree is what makes that possible, and the trade is that a `<Footnote>` has to
|
|
27
|
+
* appear in the JSX you hand to `flow()`: one hidden inside a component's own
|
|
28
|
+
* body is invisible here and stays inline.
|
|
29
|
+
*/
|
|
30
|
+
export function extractBlockFootnotes(node: ReactNode, idPrefix: string): ExtractedBlock {
|
|
31
|
+
const notes: ExtractedNote[] = [];
|
|
32
|
+
|
|
33
|
+
const walkChildren = (children: ReactNode): ReactNode => {
|
|
34
|
+
if (Array.isArray(children)) {
|
|
35
|
+
let changed = false;
|
|
36
|
+
const mapped = Children.map(children, (child) => {
|
|
37
|
+
const next = walk(child);
|
|
38
|
+
if (next !== child) changed = true;
|
|
39
|
+
return next;
|
|
40
|
+
});
|
|
41
|
+
return changed ? mapped : children;
|
|
42
|
+
}
|
|
43
|
+
return walk(children);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const walk = (current: ReactNode): ReactNode => {
|
|
47
|
+
if (!isValidElement(current)) return current;
|
|
48
|
+
|
|
49
|
+
if (isFootnoteElement(current)) {
|
|
50
|
+
const props = current.props as { id?: string; children?: ReactNode };
|
|
51
|
+
const id = props.id ?? `${idPrefix}-${notes.length + 1}`;
|
|
52
|
+
notes.push({ id, content: props.children });
|
|
53
|
+
return createElement(FootnoteMarker, { key: current.key ?? id, id });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const props = current.props as { children?: ReactNode };
|
|
57
|
+
// A render-prop child is a function, not a tree — there is nothing to walk.
|
|
58
|
+
if (props.children === undefined || typeof props.children === 'function') return current;
|
|
59
|
+
|
|
60
|
+
const next = walkChildren(props.children);
|
|
61
|
+
return next === props.children ? current : cloneElement(current, undefined, next);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const walked = walk(node);
|
|
65
|
+
return { node: walked, notes };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type PreparedSection = {
|
|
69
|
+
blocks: ReactNode[];
|
|
70
|
+
/** Notes per block, index-aligned with `blocks`. */
|
|
71
|
+
notesByBlock: ExtractedNote[][];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function extractSectionFootnotes(
|
|
75
|
+
blocks: ReactNode[],
|
|
76
|
+
sectionIndex: number,
|
|
77
|
+
): PreparedSection {
|
|
78
|
+
const out: PreparedSection = { blocks: [], notesByBlock: [] };
|
|
79
|
+
blocks.forEach((block, blockIndex) => {
|
|
80
|
+
const extracted = extractBlockFootnotes(block, `fn-${sectionIndex}-${blockIndex}`);
|
|
81
|
+
out.blocks.push(extracted.node);
|
|
82
|
+
out.notesByBlock.push(extracted.notes);
|
|
83
|
+
});
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function notesForPage(
|
|
88
|
+
notesByBlock: ExtractedNote[][],
|
|
89
|
+
blockIndices: number[],
|
|
90
|
+
): ExtractedNote[] {
|
|
91
|
+
const out: ExtractedNote[] = [];
|
|
92
|
+
for (const index of blockIndices) out.push(...(notesByBlock[index] ?? []));
|
|
93
|
+
return out;
|
|
94
|
+
}
|