@karmaniverous/jeeves-server 3.0.0-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/.env.local +13 -0
- package/.env.local.template +13 -0
- package/.tsbuildinfo +1 -0
- package/CHANGELOG.md +450 -0
- package/about.md +82 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +14 -0
- package/client/package-lock.json +5181 -0
- package/client/package.json +60 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.tsx +22 -0
- package/client/src/components/AccountMenu.tsx +167 -0
- package/client/src/components/ActionDropdown.tsx +120 -0
- package/client/src/components/CodeEditor.tsx +143 -0
- package/client/src/components/CodeViewer.tsx +113 -0
- package/client/src/components/ConfirmDialog.tsx +32 -0
- package/client/src/components/DirectoryRow.tsx +62 -0
- package/client/src/components/DirectoryTable.tsx +42 -0
- package/client/src/components/DownloadDropdown.tsx +116 -0
- package/client/src/components/DriveList.tsx +54 -0
- package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
- package/client/src/components/FileContentView.tsx +155 -0
- package/client/src/components/InlineSvgPanzoom.ts +60 -0
- package/client/src/components/LazyDiagram.ts +93 -0
- package/client/src/components/LinkDropdown.tsx +134 -0
- package/client/src/components/MarkdownView.tsx +115 -0
- package/client/src/components/MermaidViewer.tsx +21 -0
- package/client/src/components/PlantUmlViewer.tsx +21 -0
- package/client/src/components/SearchModal.tsx +424 -0
- package/client/src/components/SvgViewer.tsx +107 -0
- package/client/src/components/TabBar.tsx +96 -0
- package/client/src/components/layout/Header.tsx +270 -0
- package/client/src/components/panzoom.ts +203 -0
- package/client/src/components/renderableUtils.ts +15 -0
- package/client/src/components/runner/JobTable.tsx +153 -0
- package/client/src/components/runner/RunHistory.tsx +140 -0
- package/client/src/components/runner/StatsBar.tsx +43 -0
- package/client/src/components/runner/StatusPill.tsx +27 -0
- package/client/src/components/runner/jobTableUtils.ts +65 -0
- package/client/src/components/scrollUtils.ts +39 -0
- package/client/src/components/ui/alert-dialog.tsx +107 -0
- package/client/src/components/ui/button.tsx +40 -0
- package/client/src/components/ui/dropdown-menu.tsx +79 -0
- package/client/src/components/ui/input.tsx +26 -0
- package/client/src/components/useActionState.ts +43 -0
- package/client/src/hooks/useFileBrowser.ts +102 -0
- package/client/src/hooks/useFileData.ts +78 -0
- package/client/src/hooks/useScrollAnchor.ts +70 -0
- package/client/src/hooks/useShareSettings.ts +22 -0
- package/client/src/hooks/useTopBar.ts +27 -0
- package/client/src/index.css +281 -0
- package/client/src/lib/AuthContext.ts +27 -0
- package/client/src/lib/api.ts +239 -0
- package/client/src/lib/auth.tsx +50 -0
- package/client/src/lib/codeBlockCm6.ts +129 -0
- package/client/src/lib/codeBlockCopy.ts +43 -0
- package/client/src/lib/codemirror.ts +77 -0
- package/client/src/lib/runner-api.ts +172 -0
- package/client/src/lib/svg.ts +50 -0
- package/client/src/lib/theme.ts +34 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/main.tsx +11 -0
- package/client/src/pages/FileBrowser.tsx +135 -0
- package/client/src/pages/Home.tsx +46 -0
- package/client/src/pages/Runner.tsx +151 -0
- package/client/src/pages/RunnerJob.tsx +170 -0
- package/client/tsconfig.app.json +32 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +35 -0
- package/content/privacy.md +61 -0
- package/content/terms.md +41 -0
- package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
- package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
- package/dist/client/assets/index--MBieNJA.js +1 -0
- package/dist/client/assets/index-BENeXQI_.js +1 -0
- package/dist/client/assets/index-BbBpoOxz.js +1 -0
- package/dist/client/assets/index-BdV9g5AM.js +6 -0
- package/dist/client/assets/index-BjAilRri.js +2 -0
- package/dist/client/assets/index-BqbhWo2I.js +3 -0
- package/dist/client/assets/index-CVbycZ0H.js +1 -0
- package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
- package/dist/client/assets/index-D8KZVveX.js +1 -0
- package/dist/client/assets/index-DC4HMHxY.js +13 -0
- package/dist/client/assets/index-DbMebkkd.css +1 -0
- package/dist/client/assets/index-DcY2RXqX.js +1 -0
- package/dist/client/assets/index-Duy-tZYV.js +1 -0
- package/dist/client/assets/index-Dw7rDFmE.js +7 -0
- package/dist/client/assets/index-FlCUvrjv.js +2 -0
- package/dist/client/assets/index-K6OVmfhg.js +1 -0
- package/dist/client/assets/index-LjwgzZ7F.js +62 -0
- package/dist/client/assets/index-MLwyFRN0.js +1 -0
- package/dist/client/assets/index-OpqBpSjn.js +1 -0
- package/dist/client/assets/index-SsHei0HE.js +1 -0
- package/dist/client/assets/index-uQa2yckk.js +1 -0
- package/dist/client/assets/index-udkXoIER.js +1 -0
- package/dist/client/index.html +15 -0
- package/dist/client/vite.svg +1 -0
- package/dist/src/auth/google.js +57 -0
- package/dist/src/auth/keys.js +185 -0
- package/dist/src/auth/resolve.js +102 -0
- package/dist/src/auth/session.js +57 -0
- package/dist/src/cli/commands/config.js +100 -0
- package/dist/src/cli/commands/config.test.js +84 -0
- package/dist/src/cli/commands/service.js +93 -0
- package/dist/src/cli/commands/start.js +24 -0
- package/dist/src/cli/index.js +20 -0
- package/dist/src/config/index.js +90 -0
- package/dist/src/config/loadConfig.test.js +127 -0
- package/dist/src/config/resolve.js +134 -0
- package/dist/src/config/resolve.test.js +148 -0
- package/dist/src/config/schema.js +159 -0
- package/dist/src/config/substituteEnvVars.js +45 -0
- package/dist/src/config/substituteEnvVars.test.js +51 -0
- package/dist/src/config/types.js +5 -0
- package/dist/src/routes/api/auth-status.js +56 -0
- package/dist/src/routes/api/diagrams.js +35 -0
- package/dist/src/routes/api/directory.js +93 -0
- package/dist/src/routes/api/drives.js +15 -0
- package/dist/src/routes/api/export.js +218 -0
- package/dist/src/routes/api/fileContent.js +286 -0
- package/dist/src/routes/api/index.js +33 -0
- package/dist/src/routes/api/linkInfo.js +71 -0
- package/dist/src/routes/api/linkInfo.test.js +104 -0
- package/dist/src/routes/api/middleware.js +117 -0
- package/dist/src/routes/api/raw.js +38 -0
- package/dist/src/routes/api/runner.js +59 -0
- package/dist/src/routes/api/search.js +236 -0
- package/dist/src/routes/api/sharing.js +203 -0
- package/dist/src/routes/api/status.js +68 -0
- package/dist/src/routes/api/status.test.js +62 -0
- package/dist/src/routes/auth.js +99 -0
- package/dist/src/routes/event.js +77 -0
- package/dist/src/routes/event.test.js +206 -0
- package/dist/src/routes/health.js +10 -0
- package/dist/src/routes/keys.js +129 -0
- package/dist/src/routes/path/index.js +17 -0
- package/dist/src/routes/static.js +30 -0
- package/dist/src/server.js +90 -0
- package/dist/src/services/deepShareLinks.js +163 -0
- package/dist/src/services/diagramCache.js +104 -0
- package/dist/src/services/embeddedDiagrams.js +136 -0
- package/dist/src/services/eventLog.js +55 -0
- package/dist/src/services/eventLog.test.js +113 -0
- package/dist/src/services/eventQueue.js +154 -0
- package/dist/src/services/eventQueue.test.js +104 -0
- package/dist/src/services/export.js +220 -0
- package/dist/src/services/exportCache.js +196 -0
- package/dist/src/services/markdown.js +147 -0
- package/dist/src/services/mermaid.js +97 -0
- package/dist/src/services/plantuml.js +145 -0
- package/dist/src/services/puppeteer.js +156 -0
- package/dist/src/util/breadcrumbs.js +22 -0
- package/dist/src/util/crypto.js +56 -0
- package/dist/src/util/crypto.test.js +99 -0
- package/dist/src/util/fileDetection.js +66 -0
- package/dist/src/util/fileDetection.test.js +89 -0
- package/dist/src/util/formatters.js +43 -0
- package/dist/src/util/formatters.test.js +83 -0
- package/dist/src/util/packageVersion.js +25 -0
- package/dist/src/util/platform.js +148 -0
- package/dist/src/util/state.js +46 -0
- package/dist/vitest.config.js +12 -0
- package/favicon.svg +3 -0
- package/guides/access-decision-flow.mmd +24 -0
- package/guides/access-decision-flow.svg +1 -0
- package/guides/api-integration.md +236 -0
- package/guides/deployment.md +287 -0
- package/guides/event-gateway.md +204 -0
- package/guides/event-gateway.mmd +17 -0
- package/guides/event-gateway.svg +1 -0
- package/guides/exports.md +239 -0
- package/guides/setup.md +313 -0
- package/guides/sharing.md +204 -0
- package/jeeves-server.config.template.json +25 -0
- package/package.json +124 -0
- package/scripts/download-plantuml.js +70 -0
- package/src/auth/google.ts +93 -0
- package/src/auth/keys.ts +252 -0
- package/src/auth/resolve.ts +157 -0
- package/src/auth/session.ts +77 -0
- package/src/cli/commands/config.test.ts +107 -0
- package/src/cli/commands/config.ts +113 -0
- package/src/cli/commands/service.ts +129 -0
- package/src/cli/commands/start.ts +27 -0
- package/src/cli/index.ts +25 -0
- package/src/config/index.ts +113 -0
- package/src/config/loadConfig.test.ts +155 -0
- package/src/config/resolve.test.ts +192 -0
- package/src/config/resolve.ts +173 -0
- package/src/config/schema.ts +179 -0
- package/src/config/substituteEnvVars.test.ts +64 -0
- package/src/config/substituteEnvVars.ts +52 -0
- package/src/config/types.ts +129 -0
- package/src/routes/api/auth-status.ts +85 -0
- package/src/routes/api/diagrams.ts +53 -0
- package/src/routes/api/directory.ts +123 -0
- package/src/routes/api/drives.ts +23 -0
- package/src/routes/api/export.ts +314 -0
- package/src/routes/api/fileContent.ts +414 -0
- package/src/routes/api/index.ts +37 -0
- package/src/routes/api/linkInfo.test.ts +132 -0
- package/src/routes/api/linkInfo.ts +83 -0
- package/src/routes/api/middleware.ts +156 -0
- package/src/routes/api/raw.ts +54 -0
- package/src/routes/api/runner.ts +107 -0
- package/src/routes/api/search.ts +321 -0
- package/src/routes/api/sharing.ts +259 -0
- package/src/routes/api/status.test.ts +72 -0
- package/src/routes/api/status.ts +82 -0
- package/src/routes/auth.ts +143 -0
- package/src/routes/event.test.ts +248 -0
- package/src/routes/event.ts +109 -0
- package/src/routes/health.ts +13 -0
- package/src/routes/keys.ts +192 -0
- package/src/routes/path/index.ts +24 -0
- package/src/routes/static.ts +54 -0
- package/src/server.ts +104 -0
- package/src/services/deepShareLinks.ts +203 -0
- package/src/services/diagramCache.ts +128 -0
- package/src/services/embeddedDiagrams.ts +168 -0
- package/src/services/eventLog.test.ts +144 -0
- package/src/services/eventLog.ts +68 -0
- package/src/services/eventQueue.test.ts +127 -0
- package/src/services/eventQueue.ts +196 -0
- package/src/services/export.ts +267 -0
- package/src/services/exportCache.ts +216 -0
- package/src/services/markdown.ts +189 -0
- package/src/services/mermaid.ts +113 -0
- package/src/services/plantuml.ts +172 -0
- package/src/services/puppeteer.ts +188 -0
- package/src/types/fastify.d.ts +13 -0
- package/src/types/jsonmap.d.ts +10 -0
- package/src/types/plantuml-encoder.d.ts +4 -0
- package/src/util/breadcrumbs.ts +33 -0
- package/src/util/crypto.test.ts +132 -0
- package/src/util/crypto.ts +79 -0
- package/src/util/fileDetection.test.ts +115 -0
- package/src/util/fileDetection.ts +70 -0
- package/src/util/formatters.test.ts +105 -0
- package/src/util/formatters.ts +44 -0
- package/src/util/packageVersion.ts +30 -0
- package/src/util/platform.ts +178 -0
- package/src/util/state.ts +55 -0
- package/test-docs/diagram-retry-test.md +18 -0
- package/test-docs/embedded-diagrams.md +52 -0
- package/test-docs/lazy-diagrams-test.md +333 -0
- package/test-docs/page-a.md +7 -0
- package/test-docs/page-b.md +7 -0
- package/test-docs/page-c.md +7 -0
- package/test-docs/sub/page-d.md +7 -0
- package/test-docs/test-diagram.puml +13 -0
- package/test-docs/validate-deep-share.js +318 -0
- package/tsconfig.json +37 -0
- package/tsdoc.json +13 -0
- package/vendor/.plantuml-version +1 -0
- package/vendor/plantuml.jar +0 -0
- package/vitest.config.js +12 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlantUML rendering with fallback pipeline:
|
|
3
|
+
* 1. Local Java jar (if configured) — supports !include directives
|
|
4
|
+
* 2. Configured PlantUML servers (in order)
|
|
5
|
+
* 3. Public community server (always last resort)
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import plantumlEncoder from 'plantuml-encoder';
|
|
12
|
+
import { getConfig } from '../config/index.js';
|
|
13
|
+
/**
|
|
14
|
+
* Try rendering via local PlantUML jar.
|
|
15
|
+
* Output goes to a temp directory to avoid writing in the source tree.
|
|
16
|
+
* Returns the output buffer, or null on failure.
|
|
17
|
+
*/
|
|
18
|
+
function renderViaJar(filePath, format) {
|
|
19
|
+
const { plantuml } = getConfig();
|
|
20
|
+
if (!plantuml.jarPath)
|
|
21
|
+
return null;
|
|
22
|
+
const dir = path.dirname(filePath);
|
|
23
|
+
const ext = path.extname(filePath);
|
|
24
|
+
const base = path.basename(filePath, ext);
|
|
25
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puml-'));
|
|
26
|
+
const outFile = path.join(tmpDir, `${base}.${format}`);
|
|
27
|
+
try {
|
|
28
|
+
const java = plantuml.javaPath ?? 'java';
|
|
29
|
+
// UNSECURE profile allows remote !include directives (e.g. AWS PuML from GitHub)
|
|
30
|
+
execSync(`"${java}" -DPLANTUML_SECURITY_PROFILE=UNSECURE -jar "${plantuml.jarPath}" -t${format} -o "${tmpDir}" "${filePath}"`, { timeout: 60_000, stdio: 'pipe', cwd: dir });
|
|
31
|
+
if (!fs.existsSync(outFile))
|
|
32
|
+
return null;
|
|
33
|
+
const buffer = fs.readFileSync(outFile);
|
|
34
|
+
return buffer;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// PlantUML may produce output even on non-zero exit (e.g. partial render with warnings).
|
|
38
|
+
// Check for output before giving up.
|
|
39
|
+
if (fs.existsSync(outFile)) {
|
|
40
|
+
console.warn('[PlantUML jar] render completed with warnings');
|
|
41
|
+
if (err && typeof err === 'object' && 'stderr' in err) {
|
|
42
|
+
console.warn('[PlantUML jar] stderr:', String(err.stderr));
|
|
43
|
+
}
|
|
44
|
+
return fs.readFileSync(outFile);
|
|
45
|
+
}
|
|
46
|
+
console.error('[PlantUML jar] render failed:', err.message);
|
|
47
|
+
if (err && typeof err === 'object' && 'stderr' in err) {
|
|
48
|
+
console.error('[PlantUML jar] stderr:', String(err.stderr));
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
// Clean up temp dir
|
|
54
|
+
try {
|
|
55
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Try rendering via a PlantUML server endpoint.
|
|
64
|
+
* Returns the output buffer, or null on failure.
|
|
65
|
+
*/
|
|
66
|
+
async function renderViaServer(source, format, serverUrl) {
|
|
67
|
+
try {
|
|
68
|
+
const encoded = plantumlEncoder.encode(source);
|
|
69
|
+
const url = `${serverUrl.replace(/\/+$/, '')}/${format}/${encoded}`;
|
|
70
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
71
|
+
if (!resp.ok)
|
|
72
|
+
return null;
|
|
73
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Render a PlantUML file with the full fallback pipeline.
|
|
81
|
+
* Returns \{ buffer, method \} or null if all methods fail.
|
|
82
|
+
*/
|
|
83
|
+
export async function renderPlantUml(filePath, format = 'svg') {
|
|
84
|
+
// 1. Try local jar (supports includes)
|
|
85
|
+
const jarResult = renderViaJar(filePath, format);
|
|
86
|
+
if (jarResult)
|
|
87
|
+
return { buffer: jarResult, method: 'jar' };
|
|
88
|
+
// 2. Try configured servers in order
|
|
89
|
+
const { plantuml } = getConfig();
|
|
90
|
+
const source = fs.readFileSync(filePath, 'utf8');
|
|
91
|
+
for (const server of plantuml.servers) {
|
|
92
|
+
const serverResult = await renderViaServer(source, format, server);
|
|
93
|
+
if (serverResult)
|
|
94
|
+
return { buffer: serverResult, method: server };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Render PlantUML source string to SVG.
|
|
100
|
+
* Writes to a temp file, renders via the full pipeline, cleans up.
|
|
101
|
+
*/
|
|
102
|
+
export async function renderPlantUmlFromSource(source) {
|
|
103
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puml-src-'));
|
|
104
|
+
const inFile = path.join(tmpDir, 'diagram.puml');
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(inFile, source, 'utf8');
|
|
107
|
+
return await renderPlantUmlSvg(inFile);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
try {
|
|
111
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Render PlantUML to SVG string (convenience for file API).
|
|
120
|
+
*/
|
|
121
|
+
export async function renderPlantUmlSvg(filePath) {
|
|
122
|
+
const result = await renderPlantUml(filePath, 'svg');
|
|
123
|
+
if (!result)
|
|
124
|
+
return null;
|
|
125
|
+
return result.buffer.toString('utf8');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Render PlantUML to buffer for export.
|
|
129
|
+
*/
|
|
130
|
+
export async function renderPlantUmlToBuffer(filePath, format) {
|
|
131
|
+
const result = await renderPlantUml(filePath, format);
|
|
132
|
+
return result?.buffer ?? null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get all export formats supported by PlantUML.
|
|
136
|
+
*/
|
|
137
|
+
export function getPlantUmlFormats() {
|
|
138
|
+
// PlantUML jar supports all these; server supports svg/png/txt
|
|
139
|
+
const { plantuml } = getConfig();
|
|
140
|
+
if (plantuml.jarPath) {
|
|
141
|
+
return ['svg', 'png', 'pdf', 'eps'];
|
|
142
|
+
}
|
|
143
|
+
// Server-only: limited formats
|
|
144
|
+
return ['svg', 'png'];
|
|
145
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Puppeteer browser management and page preparation utilities.
|
|
3
|
+
* Shared by PDF and DOCX export paths.
|
|
4
|
+
*/
|
|
5
|
+
import puppeteer from 'puppeteer-core';
|
|
6
|
+
import { getConfig } from '../config/index.js';
|
|
7
|
+
/**
|
|
8
|
+
* Launch Puppeteer browser with configured Chrome path.
|
|
9
|
+
*/
|
|
10
|
+
export async function launchBrowser() {
|
|
11
|
+
const { chromePath } = getConfig();
|
|
12
|
+
return await puppeteer.launch({
|
|
13
|
+
executablePath: chromePath,
|
|
14
|
+
headless: true,
|
|
15
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Print CSS — hides chrome, normalizes layout for clean export. */
|
|
19
|
+
const PRINT_CSS = `
|
|
20
|
+
/* Hide chrome — works for both legacy server-rendered and SPA */
|
|
21
|
+
.header, .header-actions, .panzoom-container, .panzoom-hint,
|
|
22
|
+
header, nav, .toc-sidebar, [class*="sticky"], [class*="fixed"] { display: none !important; }
|
|
23
|
+
.toc { position: static !important; height: auto !important; page-break-after: always; }
|
|
24
|
+
.toc-spacer { display: none !important; }
|
|
25
|
+
.layout { display: block !important; }
|
|
26
|
+
body { background: #fff !important; font-size: 10pt !important; line-height: 1.5 !important; color: #000 !important; }
|
|
27
|
+
/* SPA layout: remove scroll containers so body grows to full content height */
|
|
28
|
+
html, body, #root { height: auto !important; overflow: visible !important; }
|
|
29
|
+
main, [class*="overflow-y"] { overflow: visible !important; height: auto !important; }
|
|
30
|
+
/* SPA article.prose */
|
|
31
|
+
article.prose { max-width: none !important; border: none !important; padding: 0 !important; }
|
|
32
|
+
.content, article.prose { font-size: 10pt !important; }
|
|
33
|
+
/* Hide tab bar and other SPA controls */
|
|
34
|
+
[role="tablist"], button { display: none !important; }
|
|
35
|
+
main { padding-top: 0 !important; }
|
|
36
|
+
/* Inline SVG Panzoom & Embedded Diagrams: strip container chrome, show SVGs cleanly */
|
|
37
|
+
.inline-svg-panzoom, .embedded-diagram-panzoom, .embedded-diagram-rendered {
|
|
38
|
+
position: static !important;
|
|
39
|
+
background: white !important;
|
|
40
|
+
border: none !important;
|
|
41
|
+
overflow: visible !important;
|
|
42
|
+
cursor: default !important;
|
|
43
|
+
padding: 0 !important;
|
|
44
|
+
margin: 1em 0 !important;
|
|
45
|
+
}
|
|
46
|
+
.inline-svg-panzoom button, .embedded-diagram-panzoom button { display: none !important; }
|
|
47
|
+
.inline-svg-panzoom .text-xs, .embedded-diagram-panzoom .text-xs { display: none !important; }
|
|
48
|
+
.inline-svg-panzoom svg, .embedded-diagram-panzoom svg, .embedded-diagram-rendered svg {
|
|
49
|
+
max-width: 190mm !important;
|
|
50
|
+
max-height: 250mm !important;
|
|
51
|
+
width: auto !important;
|
|
52
|
+
height: auto !important;
|
|
53
|
+
display: block !important;
|
|
54
|
+
page-break-inside: avoid !important;
|
|
55
|
+
}
|
|
56
|
+
h1 { font-size: 18pt !important; }
|
|
57
|
+
h2 { font-size: 14pt !important; }
|
|
58
|
+
h3 { font-size: 12pt !important; }
|
|
59
|
+
h4, h5, h6 { font-size: 10pt !important; }
|
|
60
|
+
code { font-size: 9pt !important; }
|
|
61
|
+
pre { font-size: 8pt !important; background: #f5f5f5 !important; border: 1px solid #e0e0e0 !important; border-radius: 4px !important; padding: 8px !important; overflow-wrap: break-word !important; white-space: pre-wrap !important; }
|
|
62
|
+
pre code { font-size: 8pt !important; }
|
|
63
|
+
table { font-size: 10pt !important; }
|
|
64
|
+
a.anchor { display: none !important; }
|
|
65
|
+
img, svg, .svg-container, .zoomable-svg {
|
|
66
|
+
max-width: 190mm !important;
|
|
67
|
+
max-height: 250mm !important;
|
|
68
|
+
width: auto !important;
|
|
69
|
+
height: auto !important;
|
|
70
|
+
display: block !important;
|
|
71
|
+
page-break-inside: avoid !important;
|
|
72
|
+
}
|
|
73
|
+
img { object-fit: contain !important; }
|
|
74
|
+
`;
|
|
75
|
+
/**
|
|
76
|
+
* Add print styles to a Puppeteer page.
|
|
77
|
+
*/
|
|
78
|
+
export async function addPrintStyles(page) {
|
|
79
|
+
await page.addStyleTag({ content: PRINT_CSS });
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Wait for SPA content to fully render, including async SVG fetches.
|
|
83
|
+
*/
|
|
84
|
+
export async function waitForSpaContent(page) {
|
|
85
|
+
await page
|
|
86
|
+
.waitForSelector('article.prose', { timeout: 15_000 })
|
|
87
|
+
.catch(() => { });
|
|
88
|
+
await page
|
|
89
|
+
.waitForFunction(() => {
|
|
90
|
+
const containers = document.querySelectorAll('.inline-svg-panzoom');
|
|
91
|
+
if (containers.length === 0)
|
|
92
|
+
return true;
|
|
93
|
+
return Array.from(containers).every((c) => !c.textContent.includes('Loading SVG'));
|
|
94
|
+
}, { timeout: 15_000 })
|
|
95
|
+
.catch(() => { });
|
|
96
|
+
// Wait for CM6 code blocks to mount
|
|
97
|
+
await page
|
|
98
|
+
.waitForFunction(() => {
|
|
99
|
+
const article = document.querySelector('article.prose');
|
|
100
|
+
if (!article)
|
|
101
|
+
return true;
|
|
102
|
+
// If there are no code blocks, or CM6 has signaled ready
|
|
103
|
+
const codeBlocks = article.querySelectorAll('.cm6-embedded-code');
|
|
104
|
+
if (codeBlocks.length === 0)
|
|
105
|
+
return true;
|
|
106
|
+
return article.getAttribute('data-cm6-ready') === 'true';
|
|
107
|
+
}, { timeout: 15_000 })
|
|
108
|
+
.catch(() => { });
|
|
109
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
110
|
+
}
|
|
111
|
+
/** SVG container selectors used across export paths. */
|
|
112
|
+
export const SVG_CONTAINER_SELECTORS = '.svg-container, .zoomable-svg, .inline-svg-panzoom, .embedded-diagram-panzoom, .embedded-diagram-rendered';
|
|
113
|
+
/**
|
|
114
|
+
* Capture each SVG in a page as a high-quality PNG screenshot.
|
|
115
|
+
* Returns array of \{ index, dataUrl, width, height \}.
|
|
116
|
+
*/
|
|
117
|
+
export async function captureSvgsAsPng(browser, page) {
|
|
118
|
+
const svgContents = await page.evaluate((selectors) => {
|
|
119
|
+
const containers = document.querySelectorAll(selectors);
|
|
120
|
+
return Array.from(containers).map((container, i) => {
|
|
121
|
+
const svg = container.querySelector('svg');
|
|
122
|
+
return { index: i, svgHtml: svg ? svg.outerHTML : null };
|
|
123
|
+
});
|
|
124
|
+
}, SVG_CONTAINER_SELECTORS);
|
|
125
|
+
const results = [];
|
|
126
|
+
for (const { index, svgHtml } of svgContents) {
|
|
127
|
+
if (!svgHtml)
|
|
128
|
+
continue;
|
|
129
|
+
const svgPage = await browser.newPage();
|
|
130
|
+
await svgPage.setViewport({
|
|
131
|
+
width: 1200,
|
|
132
|
+
height: 2000,
|
|
133
|
+
deviceScaleFactor: 2,
|
|
134
|
+
});
|
|
135
|
+
await svgPage.setContent(`<!DOCTYPE html>
|
|
136
|
+
<html><head><style>
|
|
137
|
+
body { margin: 0; padding: 0; background: #fff; }
|
|
138
|
+
svg { width: 1152px; height: auto; display: block; }
|
|
139
|
+
</style></head><body>${svgHtml}</body></html>`, { waitUntil: 'networkidle0' });
|
|
140
|
+
const svgHandle = await svgPage.$('svg');
|
|
141
|
+
if (svgHandle) {
|
|
142
|
+
const screenshot = await svgHandle.screenshot({ type: 'png' });
|
|
143
|
+
const box = await svgHandle.boundingBox();
|
|
144
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
145
|
+
results.push({
|
|
146
|
+
index,
|
|
147
|
+
dataUrl: `data:image/png;base64,${Buffer.from(screenshot).toString('base64')}`,
|
|
148
|
+
width: Math.ceil(box.width),
|
|
149
|
+
height: Math.ceil(box.height),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await svgPage.close();
|
|
154
|
+
}
|
|
155
|
+
return results;
|
|
156
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb filtering utilities.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Filter breadcrumbs for outsiders:
|
|
6
|
+
* - File shares: no breadcrumbs (the page stands alone)
|
|
7
|
+
* - Directory shares: trim to the share root (matchedPath)
|
|
8
|
+
*/
|
|
9
|
+
export function filterBreadcrumbsForOutsider(breadcrumbs, isInsider, matchedPath, isDirectoryView) {
|
|
10
|
+
if (isInsider)
|
|
11
|
+
return breadcrumbs;
|
|
12
|
+
if (!isDirectoryView)
|
|
13
|
+
return breadcrumbs.length > 0 ? [breadcrumbs[breadcrumbs.length - 1]] : [];
|
|
14
|
+
// For directory views, trim breadcrumbs to the matched (shared) path root
|
|
15
|
+
if (matchedPath) {
|
|
16
|
+
const normalizedMatch = matchedPath.replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
17
|
+
const matchIdx = breadcrumbs.findIndex((b) => b.path.replace(/^\/+|\/+$/g, '').toLowerCase() === normalizedMatch);
|
|
18
|
+
if (matchIdx >= 0)
|
|
19
|
+
return breadcrumbs.slice(matchIdx);
|
|
20
|
+
}
|
|
21
|
+
return breadcrumbs;
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographic utilities for key computation and verification
|
|
3
|
+
*/
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
/**
|
|
6
|
+
* Compute path-specific key: HMAC-SHA256(apiKey, normalizedPath)
|
|
7
|
+
*/
|
|
8
|
+
export function computePathKey(apiKey, urlPath) {
|
|
9
|
+
const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
10
|
+
const hash = crypto
|
|
11
|
+
.createHmac('sha256', apiKey)
|
|
12
|
+
.update(normalized)
|
|
13
|
+
.digest('hex');
|
|
14
|
+
return hash.substring(0, 32);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Compute insider key: HMAC-SHA256(apiKey, "insider")
|
|
18
|
+
* Works for any path, grants full navigation
|
|
19
|
+
*/
|
|
20
|
+
export function computeInsiderKey(apiKey) {
|
|
21
|
+
const hash = crypto
|
|
22
|
+
.createHmac('sha256', apiKey)
|
|
23
|
+
.update('insider')
|
|
24
|
+
.digest('hex');
|
|
25
|
+
return hash.substring(0, 32);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Compute outsider key with expiry: HMAC-SHA256(apiKey, path + "|" + expiry)
|
|
29
|
+
*/
|
|
30
|
+
export function computeOutsiderKeyWithExpiry(apiKey, urlPath, expiry) {
|
|
31
|
+
const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
32
|
+
const data = `${normalized}|${String(expiry)}`;
|
|
33
|
+
const hash = crypto.createHmac('sha256', apiKey).update(data).digest('hex');
|
|
34
|
+
return hash.substring(0, 32);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compute deep share key: HMAC-SHA256(seed, path + "|" + d + "|" + dirs + "|" + stack + "|" + exp)
|
|
38
|
+
* Used for share links with depth \> 0 or dirs enabled.
|
|
39
|
+
*/
|
|
40
|
+
export function computeDeepShareKey(seed, urlPath, params) {
|
|
41
|
+
const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
42
|
+
const data = `${normalized}|${String(params.depth)}|${params.dirs ? '1' : '0'}|${params.stack}|${params.exp ?? ''}`;
|
|
43
|
+
const hash = crypto.createHmac('sha256', seed).update(data).digest('hex');
|
|
44
|
+
return hash.substring(0, 32);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Timing-safe string comparison
|
|
48
|
+
*/
|
|
49
|
+
export function timingSafeEqual(a, b) {
|
|
50
|
+
try {
|
|
51
|
+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cryptographic utilities
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { computeInsiderKey, computeOutsiderKeyWithExpiry, computePathKey, timingSafeEqual, } from './crypto.js';
|
|
6
|
+
describe('crypto utilities', () => {
|
|
7
|
+
const testApiKey = 'test-api-key-12345';
|
|
8
|
+
describe('computePathKey', () => {
|
|
9
|
+
it('should compute a 32-character hex key', () => {
|
|
10
|
+
const key = computePathKey(testApiKey, '/foo/bar');
|
|
11
|
+
expect(key).toMatch(/^[a-f0-9]{32}$/);
|
|
12
|
+
});
|
|
13
|
+
it('should normalize paths (case-insensitive)', () => {
|
|
14
|
+
const key1 = computePathKey(testApiKey, '/Foo/Bar');
|
|
15
|
+
const key2 = computePathKey(testApiKey, '/foo/bar');
|
|
16
|
+
expect(key1).toBe(key2);
|
|
17
|
+
});
|
|
18
|
+
it('should normalize paths (trim slashes)', () => {
|
|
19
|
+
const key1 = computePathKey(testApiKey, '/foo/bar/');
|
|
20
|
+
const key2 = computePathKey(testApiKey, 'foo/bar');
|
|
21
|
+
expect(key1).toBe(key2);
|
|
22
|
+
});
|
|
23
|
+
it('should produce different keys for different paths', () => {
|
|
24
|
+
const key1 = computePathKey(testApiKey, '/foo/bar');
|
|
25
|
+
const key2 = computePathKey(testApiKey, '/baz/qux');
|
|
26
|
+
expect(key1).not.toBe(key2);
|
|
27
|
+
});
|
|
28
|
+
it('should produce different keys for different API keys', () => {
|
|
29
|
+
const key1 = computePathKey('api-key-1', '/foo/bar');
|
|
30
|
+
const key2 = computePathKey('api-key-2', '/foo/bar');
|
|
31
|
+
expect(key1).not.toBe(key2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('computeInsiderKey', () => {
|
|
35
|
+
it('should compute a 32-character hex key', () => {
|
|
36
|
+
const key = computeInsiderKey(testApiKey);
|
|
37
|
+
expect(key).toMatch(/^[a-f0-9]{32}$/);
|
|
38
|
+
});
|
|
39
|
+
it('should be deterministic (same API key = same insider key)', () => {
|
|
40
|
+
const key1 = computeInsiderKey(testApiKey);
|
|
41
|
+
const key2 = computeInsiderKey(testApiKey);
|
|
42
|
+
expect(key1).toBe(key2);
|
|
43
|
+
});
|
|
44
|
+
it('should produce different keys for different API keys', () => {
|
|
45
|
+
const key1 = computeInsiderKey('api-key-1');
|
|
46
|
+
const key2 = computeInsiderKey('api-key-2');
|
|
47
|
+
expect(key1).not.toBe(key2);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('computeOutsiderKeyWithExpiry', () => {
|
|
51
|
+
it('should compute a 32-character hex key', () => {
|
|
52
|
+
const expiry = Date.now() + 3600000;
|
|
53
|
+
const key = computeOutsiderKeyWithExpiry(testApiKey, '/foo/bar', expiry);
|
|
54
|
+
expect(key).toMatch(/^[a-f0-9]{32}$/);
|
|
55
|
+
});
|
|
56
|
+
it('should normalize paths (case-insensitive)', () => {
|
|
57
|
+
const expiry = Date.now() + 3600000;
|
|
58
|
+
const key1 = computeOutsiderKeyWithExpiry(testApiKey, '/Foo/Bar', expiry);
|
|
59
|
+
const key2 = computeOutsiderKeyWithExpiry(testApiKey, '/foo/bar', expiry);
|
|
60
|
+
expect(key1).toBe(key2);
|
|
61
|
+
});
|
|
62
|
+
it('should produce different keys for different expiries', () => {
|
|
63
|
+
const expiry1 = Date.now() + 3600000;
|
|
64
|
+
const expiry2 = Date.now() + 7200000;
|
|
65
|
+
const key1 = computeOutsiderKeyWithExpiry(testApiKey, '/foo/bar', expiry1);
|
|
66
|
+
const key2 = computeOutsiderKeyWithExpiry(testApiKey, '/foo/bar', expiry2);
|
|
67
|
+
expect(key1).not.toBe(key2);
|
|
68
|
+
});
|
|
69
|
+
it('should accept string expiry', () => {
|
|
70
|
+
const expiry = String(Date.now() + 3600000);
|
|
71
|
+
const key = computeOutsiderKeyWithExpiry(testApiKey, '/foo/bar', expiry);
|
|
72
|
+
expect(key).toMatch(/^[a-f0-9]{32}$/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('timingSafeEqual', () => {
|
|
76
|
+
it('should return true for equal strings', () => {
|
|
77
|
+
const a = 'abc123';
|
|
78
|
+
const b = 'abc123';
|
|
79
|
+
expect(timingSafeEqual(a, b)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('should return false for different strings', () => {
|
|
82
|
+
const a = 'abc123';
|
|
83
|
+
const b = 'def456';
|
|
84
|
+
expect(timingSafeEqual(a, b)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
it('should return false for strings of different lengths', () => {
|
|
87
|
+
const a = 'abc';
|
|
88
|
+
const b = 'abcdef';
|
|
89
|
+
expect(timingSafeEqual(a, b)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
it('should return false for empty strings vs non-empty', () => {
|
|
92
|
+
expect(timingSafeEqual('', 'abc')).toBe(false);
|
|
93
|
+
expect(timingSafeEqual('abc', '')).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it('should return true for empty strings', () => {
|
|
96
|
+
expect(timingSafeEqual('', '')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File type detection and content-type mapping
|
|
3
|
+
*/
|
|
4
|
+
import mime from 'mime-types';
|
|
5
|
+
/**
|
|
6
|
+
* Override map for extensions where mime-types returns an incorrect or
|
|
7
|
+
* unhelpful result for our use case.
|
|
8
|
+
*/
|
|
9
|
+
const OVERRIDES = {
|
|
10
|
+
'.mmd': 'text/plain; charset=utf-8',
|
|
11
|
+
'.puml': 'text/plain; charset=utf-8',
|
|
12
|
+
'.plantuml': 'text/plain; charset=utf-8',
|
|
13
|
+
'.pu': 'text/plain; charset=utf-8',
|
|
14
|
+
'.log': 'text/plain; charset=utf-8',
|
|
15
|
+
'.jsonl': 'text/plain; charset=utf-8',
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Detect if a buffer looks like text (no null bytes in first 8KB)
|
|
19
|
+
*/
|
|
20
|
+
export function looksLikeText(buffer) {
|
|
21
|
+
const checkSize = Math.min(buffer.length, 8192);
|
|
22
|
+
for (let i = 0; i < checkSize; i++) {
|
|
23
|
+
if (buffer[i] === 0)
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get content type for a file extension (with leading dot, e.g. '.md')
|
|
30
|
+
*/
|
|
31
|
+
export function getContentType(ext) {
|
|
32
|
+
const dotExt = ext.startsWith('.') ? ext : `.${ext}`;
|
|
33
|
+
if (OVERRIDES[dotExt.toLowerCase()])
|
|
34
|
+
return OVERRIDES[dotExt.toLowerCase()];
|
|
35
|
+
// mime-types expects extension without the dot
|
|
36
|
+
return mime.contentType(dotExt.slice(1)) || 'application/octet-stream';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Standard content-type map for diagram export formats.
|
|
40
|
+
*/
|
|
41
|
+
export const DIAGRAM_CONTENT_TYPES = {
|
|
42
|
+
svg: 'image/svg+xml',
|
|
43
|
+
png: 'image/png',
|
|
44
|
+
pdf: 'application/pdf',
|
|
45
|
+
eps: 'application/postscript',
|
|
46
|
+
txt: 'text/plain; charset=utf-8',
|
|
47
|
+
latex: 'application/x-latex',
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Map file extensions to highlight.js language identifiers.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* Check if a content type should be displayed inline
|
|
54
|
+
*/
|
|
55
|
+
export function isInlineType(contentType) {
|
|
56
|
+
const inlineTypes = [
|
|
57
|
+
'image/',
|
|
58
|
+
'video/',
|
|
59
|
+
'audio/',
|
|
60
|
+
'text/',
|
|
61
|
+
'application/pdf',
|
|
62
|
+
'application/json',
|
|
63
|
+
'application/xml',
|
|
64
|
+
];
|
|
65
|
+
return inlineTypes.some((type) => contentType.startsWith(type));
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for file detection utilities
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { getContentType, isInlineType, looksLikeText, } from './fileDetection.js';
|
|
6
|
+
describe('file detection', () => {
|
|
7
|
+
describe('looksLikeText', () => {
|
|
8
|
+
it('should return true for plain text', () => {
|
|
9
|
+
const buffer = Buffer.from('Hello, world!', 'utf8');
|
|
10
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
it('should return true for text with newlines', () => {
|
|
13
|
+
const buffer = Buffer.from('Line 1\nLine 2\nLine 3', 'utf8');
|
|
14
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it('should return false for binary data with null bytes', () => {
|
|
17
|
+
const buffer = Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6c, 0x6f]); // He\0llo
|
|
18
|
+
expect(looksLikeText(buffer)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it('should return false for binary data at start', () => {
|
|
21
|
+
const buffer = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
|
22
|
+
expect(looksLikeText(buffer)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
it('should only check first 8KB', () => {
|
|
25
|
+
// Create 10KB buffer with null byte at position 9000
|
|
26
|
+
const buffer = Buffer.alloc(10240);
|
|
27
|
+
buffer.fill('A'.charCodeAt(0), 0, 9000);
|
|
28
|
+
buffer[9000] = 0;
|
|
29
|
+
buffer.fill('A'.charCodeAt(0), 9001);
|
|
30
|
+
// Should return true because null byte is beyond 8KB
|
|
31
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('should handle empty buffer', () => {
|
|
34
|
+
const buffer = Buffer.alloc(0);
|
|
35
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('getContentType', () => {
|
|
39
|
+
it('should return correct type for text files', () => {
|
|
40
|
+
expect(getContentType('.txt')).toBe('text/plain; charset=utf-8');
|
|
41
|
+
expect(getContentType('.json')).toBe('application/json; charset=utf-8');
|
|
42
|
+
expect(getContentType('.html')).toBe('text/html; charset=utf-8');
|
|
43
|
+
});
|
|
44
|
+
it('should return correct type for images', () => {
|
|
45
|
+
expect(getContentType('.png')).toBe('image/png');
|
|
46
|
+
expect(getContentType('.jpg')).toBe('image/jpeg');
|
|
47
|
+
expect(getContentType('.svg')).toBe('image/svg+xml');
|
|
48
|
+
});
|
|
49
|
+
it('should return correct type for documents', () => {
|
|
50
|
+
expect(getContentType('.pdf')).toBe('application/pdf');
|
|
51
|
+
expect(getContentType('.docx')).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
52
|
+
});
|
|
53
|
+
it('should be case-insensitive', () => {
|
|
54
|
+
expect(getContentType('.PDF')).toBe('application/pdf');
|
|
55
|
+
expect(getContentType('.TxT')).toBe('text/plain; charset=utf-8');
|
|
56
|
+
});
|
|
57
|
+
it('should return octet-stream for unknown extensions', () => {
|
|
58
|
+
expect(getContentType('.unknown')).toBe('application/octet-stream');
|
|
59
|
+
expect(getContentType('.xyz123')).toBe('application/octet-stream');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('isInlineType', () => {
|
|
63
|
+
it('should return true for text types', () => {
|
|
64
|
+
expect(isInlineType('text/plain; charset=utf-8')).toBe(true);
|
|
65
|
+
expect(isInlineType('text/html')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
it('should return true for images', () => {
|
|
68
|
+
expect(isInlineType('image/png')).toBe(true);
|
|
69
|
+
expect(isInlineType('image/jpeg')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('should return true for video/audio', () => {
|
|
72
|
+
expect(isInlineType('video/mp4')).toBe(true);
|
|
73
|
+
expect(isInlineType('audio/mpeg')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it('should return true for JSON and PDF', () => {
|
|
76
|
+
expect(isInlineType('application/json; charset=utf-8')).toBe(true);
|
|
77
|
+
expect(isInlineType('application/pdf')).toBe(true);
|
|
78
|
+
expect(isInlineType('application/xml')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it('should return false for archives', () => {
|
|
81
|
+
expect(isInlineType('application/zip')).toBe(false);
|
|
82
|
+
expect(isInlineType('application/x-tar')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it('should return false for executables', () => {
|
|
85
|
+
expect(isInlineType('application/octet-stream')).toBe(false);
|
|
86
|
+
expect(isInlineType('application/x-msdownload')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting utilities for dates, file sizes, etc.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Format file size in human-readable format
|
|
6
|
+
*/
|
|
7
|
+
export function formatSize(bytes) {
|
|
8
|
+
if (bytes === 0)
|
|
9
|
+
return '0 B';
|
|
10
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
11
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
12
|
+
const value = bytes / Math.pow(1024, i);
|
|
13
|
+
const decimals = i > 0 ? 1 : 0;
|
|
14
|
+
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Format ISO timestamp as relative time (e.g., "2h ago")
|
|
18
|
+
*/
|
|
19
|
+
export function formatRelativeTime(isoTimestamp) {
|
|
20
|
+
if (!isoTimestamp)
|
|
21
|
+
return null;
|
|
22
|
+
const then = new Date(isoTimestamp).getTime();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const diffMs = now - then;
|
|
25
|
+
if (diffMs < 0)
|
|
26
|
+
return null;
|
|
27
|
+
const mins = Math.floor(diffMs / 60000);
|
|
28
|
+
const hours = Math.floor(mins / 60);
|
|
29
|
+
const days = Math.floor(hours / 24);
|
|
30
|
+
if (days > 0)
|
|
31
|
+
return `${String(days)}d ago`;
|
|
32
|
+
if (hours > 0)
|
|
33
|
+
return `${String(hours)}h ago`;
|
|
34
|
+
if (mins > 0)
|
|
35
|
+
return `${String(mins)}m ago`;
|
|
36
|
+
return 'just now';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Get current ISO timestamp
|
|
40
|
+
*/
|
|
41
|
+
export function nowIso() {
|
|
42
|
+
return new Date().toISOString();
|
|
43
|
+
}
|