mosage 0.1.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/LICENSE +1 -0
- package/README.md +16 -217
- 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/init-Bbtj2pxF.js +262 -0
- 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 +84 -64
- package/skills/apply-comments/SKILL.md +43 -56
- package/skills/create-doc/SKILL.md +106 -0
- package/skills/create-theme/SKILL.md +184 -0
- package/skills/current-doc/SKILL.md +120 -0
- package/skills/doc-authoring/SKILL.md +434 -0
- package/skills/doc-authoring/references/assets.md +47 -0
- package/skills/doc-authoring/references/design-system.md +81 -0
- package/skills/doc-authoring/references/long-form.md +131 -0
- package/skills/doc-authoring/references/pagination.md +118 -0
- package/skills/doc-authoring/references/tables-and-charts.md +161 -0
- 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 +27 -0
- package/template/README.md +39 -0
- package/template/docs/getting-started/index.tsx +230 -0
- package/template/mosage.config.ts +5 -0
- package/template/package.json +24 -0
- package/template/tsconfig.json +17 -0
- package/README.en.md +0 -57
- package/dist/cli.js +0 -4545
- package/dist/web/assets/index-Czg2WeHe.js +0 -182
- package/dist/web/assets/index-DKAyt92W.css +0 -1
- package/dist/web/index.html +0 -15
- package/skills/current-position/SKILL.md +0 -65
- package/skills/kickoff/SKILL.md +0 -105
- package/skills/mosage-reference/SKILL.md +0 -164
- package/skills/outline/SKILL.md +0 -79
- package/skills/review/SKILL.md +0 -64
- package/skills/write-chapter/SKILL.md +0 -58
- package/template/book/STYLE.md +0 -6
- package/template/book/assets/.gitkeep +0 -0
- package/template/book/book.yaml +0 -42
- package/template/book/brief.md +0 -6
- package/template/book/chapters/.gitkeep +0 -0
- package/template/book/notes/README.md +0 -7
- package/template/project/AGENTS.md +0 -82
- package/template/project/CLAUDE.md +0 -1
- package/template/project/README.md +0 -48
- package/template/project/books/.gitkeep +0 -0
- package/template/project/gitignore +0 -5
- package/template/project/mosage.yaml +0 -23
- package/template/project/notes/README.md +0 -8
- package/template/project/package.json +0 -14
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { type DocPage, isPageSizeName, resolvePageGeometry } from '../../lib/sdk';
|
|
3
|
+
import { loadThemeDemo, type ThemeDemoModule, type ThemeMeta } from '../../lib/themes';
|
|
4
|
+
import { PageFrame } from '../page-frame';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
theme: ThemeMeta;
|
|
8
|
+
width: number;
|
|
9
|
+
/** Render every demo page instead of just the first. */
|
|
10
|
+
all?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function ThemePreview({ theme, width, all = false }: Props) {
|
|
14
|
+
const [demo, setDemo] = useState<ThemeDemoModule | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!theme.hasDemo) return;
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
loadThemeDemo(theme.id)
|
|
20
|
+
.then((mod) => {
|
|
21
|
+
if (!cancelled) setDemo(mod);
|
|
22
|
+
})
|
|
23
|
+
.catch(() => {
|
|
24
|
+
if (!cancelled) setDemo(null);
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
cancelled = true;
|
|
28
|
+
};
|
|
29
|
+
}, [theme.id, theme.hasDemo]);
|
|
30
|
+
|
|
31
|
+
const geometry = resolvePageGeometry(
|
|
32
|
+
isPageSizeName(theme.pageSize) ? { pageSize: theme.pageSize } : undefined,
|
|
33
|
+
);
|
|
34
|
+
const scale = width / geometry.width;
|
|
35
|
+
const pages = (demo?.default ?? []).filter(
|
|
36
|
+
(entry): entry is DocPage => typeof entry === 'function',
|
|
37
|
+
);
|
|
38
|
+
const shown = all ? pages : pages.slice(0, 1);
|
|
39
|
+
|
|
40
|
+
if (shown.length === 0) {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className="grid place-items-center rounded-md border border-border border-dashed bg-muted text-[11px] text-muted-foreground"
|
|
44
|
+
style={{ width, height: geometry.height * scale }}
|
|
45
|
+
>
|
|
46
|
+
{theme.hasDemo ? 'Loading…' : 'No demo'}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-wrap gap-4">
|
|
53
|
+
{shown.map((Page, index) => (
|
|
54
|
+
<div
|
|
55
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: page order is the identity
|
|
56
|
+
key={index}
|
|
57
|
+
className="overflow-hidden rounded-md ring-1 ring-border"
|
|
58
|
+
style={{ width, height: geometry.height * scale }}
|
|
59
|
+
>
|
|
60
|
+
<PageFrame
|
|
61
|
+
index={index}
|
|
62
|
+
total={pages.length}
|
|
63
|
+
geometry={geometry}
|
|
64
|
+
scale={scale}
|
|
65
|
+
design={demo?.design}
|
|
66
|
+
flat
|
|
67
|
+
>
|
|
68
|
+
<Page />
|
|
69
|
+
</PageFrame>
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { cn } from '../../lib/utils';
|
|
11
|
+
|
|
12
|
+
type Placement = 'bottom-start' | 'bottom-end' | 'right-start';
|
|
13
|
+
|
|
14
|
+
type MenuProps = {
|
|
15
|
+
trigger: (props: {
|
|
16
|
+
onClick: (e: React.MouseEvent) => void;
|
|
17
|
+
'aria-expanded': boolean;
|
|
18
|
+
}) => ReactNode;
|
|
19
|
+
children: (close: () => void) => ReactNode;
|
|
20
|
+
placement?: Placement;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Minimal anchored menu. The runtime ships to users, so the browser UI stays
|
|
26
|
+
* dependency-free rather than pulling in a headless-component library.
|
|
27
|
+
*/
|
|
28
|
+
export function Menu({ trigger, children, placement = 'bottom-end', className }: MenuProps) {
|
|
29
|
+
const [open, setOpen] = useState(false);
|
|
30
|
+
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
|
|
31
|
+
const anchorRef = useRef<HTMLSpanElement>(null);
|
|
32
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const id = useId();
|
|
34
|
+
|
|
35
|
+
const close = useCallback(() => setOpen(false), []);
|
|
36
|
+
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
if (!open) return;
|
|
39
|
+
const anchor = anchorRef.current?.firstElementChild ?? anchorRef.current;
|
|
40
|
+
const panel = panelRef.current;
|
|
41
|
+
if (!anchor || !panel) return;
|
|
42
|
+
const a = anchor.getBoundingClientRect();
|
|
43
|
+
const p = panel.getBoundingClientRect();
|
|
44
|
+
const gap = 4;
|
|
45
|
+
let top = placement === 'right-start' ? a.top : a.bottom + gap;
|
|
46
|
+
let left = placement === 'bottom-end' ? a.right - p.width : a.left;
|
|
47
|
+
if (placement === 'right-start') left = a.right + gap;
|
|
48
|
+
left = Math.max(8, Math.min(left, window.innerWidth - p.width - 8));
|
|
49
|
+
top = Math.max(8, Math.min(top, window.innerHeight - p.height - 8));
|
|
50
|
+
setCoords({ top, left });
|
|
51
|
+
}, [open, placement]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!open) return;
|
|
55
|
+
const onPointerDown = (e: MouseEvent) => {
|
|
56
|
+
const target = e.target as Node;
|
|
57
|
+
if (panelRef.current?.contains(target)) return;
|
|
58
|
+
if (anchorRef.current?.contains(target)) return;
|
|
59
|
+
setOpen(false);
|
|
60
|
+
};
|
|
61
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
62
|
+
if (e.key === 'Escape') setOpen(false);
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener('mousedown', onPointerDown);
|
|
65
|
+
document.addEventListener('keydown', onKeyDown);
|
|
66
|
+
window.addEventListener('resize', close);
|
|
67
|
+
window.addEventListener('scroll', close, true);
|
|
68
|
+
return () => {
|
|
69
|
+
document.removeEventListener('mousedown', onPointerDown);
|
|
70
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
71
|
+
window.removeEventListener('resize', close);
|
|
72
|
+
window.removeEventListener('scroll', close, true);
|
|
73
|
+
};
|
|
74
|
+
}, [open, close]);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<span ref={anchorRef} className="contents">
|
|
79
|
+
{trigger({
|
|
80
|
+
onClick: (e) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
e.stopPropagation();
|
|
83
|
+
setOpen((v) => !v);
|
|
84
|
+
},
|
|
85
|
+
'aria-expanded': open,
|
|
86
|
+
})}
|
|
87
|
+
</span>
|
|
88
|
+
{open && (
|
|
89
|
+
<div
|
|
90
|
+
ref={panelRef}
|
|
91
|
+
id={id}
|
|
92
|
+
role="menu"
|
|
93
|
+
className={cn(
|
|
94
|
+
'fixed z-50 min-w-[180px] rounded-md border border-border bg-background p-1 shadow-lg',
|
|
95
|
+
coords ? 'visible' : 'invisible',
|
|
96
|
+
className,
|
|
97
|
+
)}
|
|
98
|
+
style={{ top: coords?.top ?? 0, left: coords?.left ?? 0 }}
|
|
99
|
+
>
|
|
100
|
+
{children(close)}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function MenuItem({
|
|
108
|
+
onClick,
|
|
109
|
+
children,
|
|
110
|
+
destructive = false,
|
|
111
|
+
active = false,
|
|
112
|
+
disabled = false,
|
|
113
|
+
}: {
|
|
114
|
+
onClick: () => void;
|
|
115
|
+
children: ReactNode;
|
|
116
|
+
destructive?: boolean;
|
|
117
|
+
active?: boolean;
|
|
118
|
+
disabled?: boolean;
|
|
119
|
+
}) {
|
|
120
|
+
return (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
role="menuitem"
|
|
124
|
+
disabled={disabled}
|
|
125
|
+
onClick={(e) => {
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
onClick();
|
|
128
|
+
}}
|
|
129
|
+
className={cn(
|
|
130
|
+
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent',
|
|
131
|
+
active && 'bg-accent',
|
|
132
|
+
destructive && 'text-red-600 dark:text-red-400',
|
|
133
|
+
disabled && 'pointer-events-none opacity-40',
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</button>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function MenuSeparator() {
|
|
142
|
+
return <div className="my-1 h-px bg-border" />;
|
|
143
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>MoSage</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { diagnosePages, type LayoutFinding } from './diagnostics';
|
|
3
|
+
import type { FileBundle } from './export-dom';
|
|
4
|
+
import { buildDocHtmlBundle } from './export-html';
|
|
5
|
+
import { mountPrintCopy } from './export-pdf';
|
|
6
|
+
import type { DocModule, PageGeometry } from './sdk';
|
|
7
|
+
import type { ExpandedPage } from './use-doc-pages';
|
|
8
|
+
|
|
9
|
+
export const BRIDGE_KEY = '__mosage';
|
|
10
|
+
|
|
11
|
+
export type BridgeStatus = {
|
|
12
|
+
docId: string;
|
|
13
|
+
title: string;
|
|
14
|
+
/** False while the flow packer is still measuring — page count is not final yet. */
|
|
15
|
+
ready: boolean;
|
|
16
|
+
pageCount: number;
|
|
17
|
+
geometry: PageGeometry;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type BridgeReport = BridgeStatus & { findings: LayoutFinding[] };
|
|
21
|
+
|
|
22
|
+
export type BridgeBundle = { filename: string; mimeType: string; base64: string };
|
|
23
|
+
|
|
24
|
+
export type MoSageBridge = {
|
|
25
|
+
version: 1;
|
|
26
|
+
status(): BridgeStatus;
|
|
27
|
+
/** Mounts the print copy and reads the layout back from it. */
|
|
28
|
+
diagnose(): Promise<BridgeReport>;
|
|
29
|
+
/** Mounts the print copy and leaves it up, for `page.pdf()` or a screenshot. */
|
|
30
|
+
preparePrint(): Promise<{ pageCount: number }>;
|
|
31
|
+
releasePrint(): void;
|
|
32
|
+
htmlBundle(): Promise<BridgeBundle | null>;
|
|
33
|
+
docxBundle(): Promise<BridgeBundle | null>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type BridgeInput = {
|
|
37
|
+
docId: string;
|
|
38
|
+
doc: DocModule | null;
|
|
39
|
+
pages: ExpandedPage[];
|
|
40
|
+
geometry: PageGeometry;
|
|
41
|
+
measuring: boolean;
|
|
42
|
+
oversized: Array<{ section: number; block: number }>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type GlobalWithBridge = typeof globalThis & { [BRIDGE_KEY]?: MoSageBridge };
|
|
46
|
+
|
|
47
|
+
function toBase64(bytes: Uint8Array): string {
|
|
48
|
+
let binary = '';
|
|
49
|
+
const CHUNK = 0x8000;
|
|
50
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
51
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
52
|
+
}
|
|
53
|
+
return btoa(binary);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toBridge(bundle: FileBundle | null): BridgeBundle | null {
|
|
57
|
+
return (
|
|
58
|
+
bundle && {
|
|
59
|
+
filename: bundle.filename,
|
|
60
|
+
mimeType: bundle.mimeType,
|
|
61
|
+
base64: toBase64(bundle.bytes),
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Publishes the rendered document to whoever is driving the page from outside —
|
|
68
|
+
* `mosage export`, `check_layout`, `render_page`. The viewer already owns the
|
|
69
|
+
* measured page list and the print pipeline; the bridge just hands them to a
|
|
70
|
+
* headless caller instead of to a Download button, so an agent sees exactly the
|
|
71
|
+
* sheets a person would.
|
|
72
|
+
*/
|
|
73
|
+
export function useAgentBridge(input: BridgeInput): void {
|
|
74
|
+
const latest = useRef(input);
|
|
75
|
+
latest.current = input;
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const g = globalThis as GlobalWithBridge;
|
|
79
|
+
let held: { dispose: () => void } | null = null;
|
|
80
|
+
|
|
81
|
+
const status = (): BridgeStatus => {
|
|
82
|
+
const { docId, doc, pages, geometry, measuring } = latest.current;
|
|
83
|
+
return {
|
|
84
|
+
docId,
|
|
85
|
+
title: doc?.meta?.title ?? docId,
|
|
86
|
+
ready: doc !== null && !measuring && pages.length > 0,
|
|
87
|
+
pageCount: pages.length,
|
|
88
|
+
geometry,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const release = () => {
|
|
93
|
+
held?.dispose();
|
|
94
|
+
held = null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
g[BRIDGE_KEY] = {
|
|
98
|
+
version: 1,
|
|
99
|
+
status,
|
|
100
|
+
async diagnose() {
|
|
101
|
+
const { docId, doc, pages, geometry, oversized } = latest.current;
|
|
102
|
+
const snapshot = status();
|
|
103
|
+
if (!doc || pages.length === 0) return { ...snapshot, findings: [] };
|
|
104
|
+
const copy = await mountPrintCopy(doc, docId, pages);
|
|
105
|
+
try {
|
|
106
|
+
return {
|
|
107
|
+
...snapshot,
|
|
108
|
+
findings: diagnosePages(copy.root, geometry, { oversized }),
|
|
109
|
+
};
|
|
110
|
+
} finally {
|
|
111
|
+
copy.dispose();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async preparePrint() {
|
|
115
|
+
release();
|
|
116
|
+
const { docId, doc, pages } = latest.current;
|
|
117
|
+
if (!doc || pages.length === 0) return { pageCount: 0 };
|
|
118
|
+
held = await mountPrintCopy(doc, docId, pages);
|
|
119
|
+
return { pageCount: pages.length };
|
|
120
|
+
},
|
|
121
|
+
releasePrint: release,
|
|
122
|
+
async htmlBundle() {
|
|
123
|
+
const { docId, doc, pages } = latest.current;
|
|
124
|
+
if (!doc) return null;
|
|
125
|
+
return toBridge(await buildDocHtmlBundle(doc, docId, pages));
|
|
126
|
+
},
|
|
127
|
+
async docxBundle() {
|
|
128
|
+
const { docId, doc, pages } = latest.current;
|
|
129
|
+
if (!doc) return null;
|
|
130
|
+
const { buildDocxBundle } = await import('./export-docx');
|
|
131
|
+
return toBridge(await buildDocxBundle(doc, docId, pages));
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
release();
|
|
137
|
+
delete g[BRIDGE_KEY];
|
|
138
|
+
};
|
|
139
|
+
}, []);
|
|
140
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export const GLOBAL_SCOPE = '@global';
|
|
4
|
+
|
|
5
|
+
export type Asset = {
|
|
6
|
+
name: string;
|
|
7
|
+
size: number;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
mtime: number;
|
|
10
|
+
mime: string;
|
|
11
|
+
url: string;
|
|
12
|
+
importPath: string;
|
|
13
|
+
unused: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AssetUsages = {
|
|
17
|
+
usages: Array<{ docId: string; count: number }>;
|
|
18
|
+
totalCount: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ApiResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
22
|
+
|
|
23
|
+
const base = (scope: string) => `/__assets/${encodeURIComponent(scope)}`;
|
|
24
|
+
const fileUrl = (scope: string, name: string) => `${base(scope)}/${encodeURIComponent(name)}`;
|
|
25
|
+
|
|
26
|
+
async function errorFrom(res: Response): Promise<string> {
|
|
27
|
+
try {
|
|
28
|
+
const body = (await res.json()) as { error?: string };
|
|
29
|
+
return body.error ?? `HTTP ${res.status}`;
|
|
30
|
+
} catch {
|
|
31
|
+
return `HTTP ${res.status}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function listAssets(scope: string): Promise<ApiResult<Asset[]>> {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(base(scope));
|
|
38
|
+
if (!res.ok) return { ok: false, error: await errorFrom(res) };
|
|
39
|
+
const body = (await res.json()) as { assets: Asset[] };
|
|
40
|
+
return { ok: true, value: body.assets };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { ok: false, error: String((err as Error).message) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function uploadAsset(
|
|
47
|
+
scope: string,
|
|
48
|
+
file: File,
|
|
49
|
+
opts: { overwrite?: boolean } = {},
|
|
50
|
+
): Promise<ApiResult<Asset>> {
|
|
51
|
+
try {
|
|
52
|
+
const url = `${fileUrl(scope, file.name)}${opts.overwrite ? '?overwrite=1' : ''}`;
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': file.type || 'application/octet-stream' },
|
|
56
|
+
body: file,
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) return { ok: false, error: await errorFrom(res) };
|
|
59
|
+
const body = (await res.json()) as Asset;
|
|
60
|
+
return { ok: true, value: { ...body, unused: true } };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { ok: false, error: String((err as Error).message) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function renameAsset(
|
|
67
|
+
scope: string,
|
|
68
|
+
name: string,
|
|
69
|
+
next: string,
|
|
70
|
+
): Promise<ApiResult<string>> {
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(fileUrl(scope, name), {
|
|
73
|
+
method: 'PATCH',
|
|
74
|
+
headers: { 'content-type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ name: next }),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) return { ok: false, error: await errorFrom(res) };
|
|
78
|
+
const body = (await res.json()) as { name: string };
|
|
79
|
+
return { ok: true, value: body.name };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { ok: false, error: String((err as Error).message) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function deleteAsset(scope: string, name: string): Promise<ApiResult<true>> {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(fileUrl(scope, name), { method: 'DELETE' });
|
|
88
|
+
if (!res.ok) return { ok: false, error: await errorFrom(res) };
|
|
89
|
+
return { ok: true, value: true };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { ok: false, error: String((err as Error).message) };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function assetUsages(scope: string, name: string): Promise<ApiResult<AssetUsages>> {
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`${fileUrl(scope, name)}/usages`);
|
|
98
|
+
if (!res.ok) return { ok: false, error: await errorFrom(res) };
|
|
99
|
+
return { ok: true, value: (await res.json()) as AssetUsages };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, error: String((err as Error).message) };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Count of global assets, for the sidebar badge. Zero outside dev. */
|
|
106
|
+
export function useAssetCount(): number {
|
|
107
|
+
const [count, setCount] = useState(0);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!import.meta.env.DEV) return;
|
|
111
|
+
let cancelled = false;
|
|
112
|
+
const read = () => {
|
|
113
|
+
listAssets(GLOBAL_SCOPE).then((result) => {
|
|
114
|
+
if (!cancelled && result.ok) setCount(result.value.length);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
read();
|
|
118
|
+
if (!import.meta.hot) {
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
import.meta.hot.on('mosage:files-changed', read);
|
|
124
|
+
return () => {
|
|
125
|
+
cancelled = true;
|
|
126
|
+
import.meta.hot?.off('mosage:files-changed', read);
|
|
127
|
+
};
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
return count;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatBytes(bytes: number): string {
|
|
134
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
135
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
|
|
136
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isPreviewable(mime: string): boolean {
|
|
140
|
+
return mime.startsWith('image/');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The line an author pastes into a document to use this asset. */
|
|
144
|
+
export function importSnippet(asset: Asset): string {
|
|
145
|
+
const stem = asset.name.replace(/\.[^.]+$/, '');
|
|
146
|
+
const ident = stem
|
|
147
|
+
.replace(/[^A-Za-z0-9]+(.)/g, (_, c: string) => c.toUpperCase())
|
|
148
|
+
.replace(/[^A-Za-z0-9]/g, '');
|
|
149
|
+
const safeIdent = /^[A-Za-z_$]/.test(ident) ? ident : `asset${ident}`;
|
|
150
|
+
return `import ${safeIdent} from '${asset.importPath}';`;
|
|
151
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type DesignSystem, defaultDesign } from './design';
|
|
2
|
+
|
|
3
|
+
const SANS_SYSTEM = '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif';
|
|
4
|
+
const SANS_HELV = '"Helvetica Neue", Helvetica, Arial, sans-serif';
|
|
5
|
+
const SERIF_GEORGIA = 'Georgia, "Times New Roman", serif';
|
|
6
|
+
const SERIF_TIMES = '"Times New Roman", Times, serif';
|
|
7
|
+
const MONO_SF = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Print-safe presets: white or near-white sheets, body type at or above 13px,
|
|
11
|
+
* one accent each. A document that prints is not the place for a dark canvas.
|
|
12
|
+
*/
|
|
13
|
+
export const designPresets: DesignSystem[] = [
|
|
14
|
+
defaultDesign,
|
|
15
|
+
{
|
|
16
|
+
palette: {
|
|
17
|
+
bg: '#ffffff',
|
|
18
|
+
text: '#111827',
|
|
19
|
+
muted: '#6b7280',
|
|
20
|
+
accent: '#0f766e',
|
|
21
|
+
rule: '#e5e7eb',
|
|
22
|
+
},
|
|
23
|
+
fonts: { heading: SERIF_GEORGIA, body: SANS_SYSTEM, mono: MONO_SF },
|
|
24
|
+
typeScale: { title: 46, h1: 27, h2: 20, h3: 16, body: 14, caption: 10 },
|
|
25
|
+
margin: 84,
|
|
26
|
+
leading: 1.6,
|
|
27
|
+
radius: 4,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
palette: {
|
|
31
|
+
bg: '#fcfbf7',
|
|
32
|
+
text: '#1c1917',
|
|
33
|
+
muted: '#78716c',
|
|
34
|
+
accent: '#9a3412',
|
|
35
|
+
rule: '#e7e2d6',
|
|
36
|
+
},
|
|
37
|
+
fonts: { heading: SERIF_TIMES, body: SERIF_GEORGIA, mono: MONO_SF },
|
|
38
|
+
typeScale: { title: 44, h1: 26, h2: 19, h3: 15, body: 14, caption: 10 },
|
|
39
|
+
margin: 90,
|
|
40
|
+
leading: 1.65,
|
|
41
|
+
radius: 2,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
palette: {
|
|
45
|
+
bg: '#ffffff',
|
|
46
|
+
text: '#0a0a0a',
|
|
47
|
+
muted: '#737373',
|
|
48
|
+
accent: '#dc2626',
|
|
49
|
+
rule: '#e5e5e5',
|
|
50
|
+
},
|
|
51
|
+
fonts: { heading: SANS_HELV, body: SANS_HELV, mono: MONO_SF },
|
|
52
|
+
typeScale: { title: 52, h1: 30, h2: 21, h3: 16, body: 14, caption: 10 },
|
|
53
|
+
margin: 72,
|
|
54
|
+
leading: 1.5,
|
|
55
|
+
radius: 0,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
palette: {
|
|
59
|
+
bg: '#f8fafc',
|
|
60
|
+
text: '#0f172a',
|
|
61
|
+
muted: '#64748b',
|
|
62
|
+
accent: '#1d4ed8',
|
|
63
|
+
rule: '#dbe2ea',
|
|
64
|
+
},
|
|
65
|
+
fonts: { heading: SANS_SYSTEM, body: SANS_SYSTEM, mono: MONO_SF },
|
|
66
|
+
typeScale: { title: 42, h1: 26, h2: 19, h3: 15, body: 13, caption: 10 },
|
|
67
|
+
margin: 76,
|
|
68
|
+
leading: 1.55,
|
|
69
|
+
radius: 8,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
palette: {
|
|
73
|
+
bg: '#ffffff',
|
|
74
|
+
text: '#18181b',
|
|
75
|
+
muted: '#71717a',
|
|
76
|
+
accent: '#7c3aed',
|
|
77
|
+
rule: '#e4e4e7',
|
|
78
|
+
},
|
|
79
|
+
fonts: { heading: SANS_SYSTEM, body: SANS_SYSTEM, mono: MONO_SF },
|
|
80
|
+
typeScale: { title: 48, h1: 28, h2: 20, h3: 16, body: 15, caption: 11 },
|
|
81
|
+
margin: 96,
|
|
82
|
+
leading: 1.62,
|
|
83
|
+
radius: 12,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
palette: {
|
|
87
|
+
bg: '#ffffff',
|
|
88
|
+
text: '#0b1220',
|
|
89
|
+
muted: '#5b6472',
|
|
90
|
+
accent: '#b45309',
|
|
91
|
+
rule: '#e6e8eb',
|
|
92
|
+
},
|
|
93
|
+
fonts: { heading: MONO_SF, body: SANS_SYSTEM, mono: MONO_SF },
|
|
94
|
+
typeScale: { title: 40, h1: 25, h2: 18, h3: 15, body: 13, caption: 10 },
|
|
95
|
+
margin: 70,
|
|
96
|
+
leading: 1.5,
|
|
97
|
+
radius: 3,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
export function shuffleDesign(current?: DesignSystem | null): DesignSystem {
|
|
102
|
+
if (designPresets.length <= 1) return defaultDesign;
|
|
103
|
+
const currentJson = current ? JSON.stringify(current) : null;
|
|
104
|
+
for (let i = 0; i < 8; i++) {
|
|
105
|
+
const pick = designPresets[Math.floor(Math.random() * designPresets.length)];
|
|
106
|
+
if (pick && JSON.stringify(pick) !== currentJson) return pick;
|
|
107
|
+
}
|
|
108
|
+
return designPresets[0] ?? defaultDesign;
|
|
109
|
+
}
|