mdv-live 0.5.4 → 0.5.8
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/CHANGELOG.md +127 -0
- package/README.md +154 -23
- package/bin/mdv.js +141 -81
- package/package.json +1 -1
- package/src/api/marpNote/guards.js +79 -0
- package/src/api/marpNote/handleGet.js +65 -0
- package/src/api/marpNote/handlePut.js +162 -0
- package/src/api/marpNote/readDeck.js +42 -0
- package/src/api/marpNote.js +40 -0
- package/src/api/pdf.js +65 -8
- package/src/concurrency/pathLock.js +39 -0
- package/src/rendering/index.js +9 -1
- package/src/rendering/markdown.js +24 -21
- package/src/rendering/marp.js +11 -32
- package/src/rendering/marpNoteWriter.js +156 -0
- package/src/rendering/marpitAdapter.js +139 -0
- package/src/server.js +29 -4
- package/src/static/app.js +369 -22
- package/src/static/index.html +24 -0
- package/src/static/lib/apiClient.js +73 -0
- package/src/static/lib/presenterChannel.js +33 -0
- package/src/static/lib/saveQueue.js +71 -0
- package/src/static/lib/tabRegistry.js +32 -0
- package/src/static/presenter.html +687 -0
- package/src/static/styles.css +34 -0
- package/src/styles/index.js +90 -0
- package/src/styles/report.example.css +201 -0
- package/src/styles/report.pdf-options.example.json +10 -0
- package/src/utils/atomicWrite.js +159 -0
- package/src/utils/errors.js +50 -0
- package/src/utils/etag.js +11 -0
- package/src/utils/lineMath.js +86 -0
- package/src/rendering/slides.js +0 -152
package/src/static/styles.css
CHANGED
|
@@ -241,6 +241,40 @@ body {
|
|
|
241
241
|
|
|
242
242
|
.toolbar-spacer { flex: 1; }
|
|
243
243
|
|
|
244
|
+
.pdf-style-panel {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
gap: 10px;
|
|
248
|
+
padding: 8px 16px;
|
|
249
|
+
border-bottom: 1px solid var(--border);
|
|
250
|
+
background: var(--bg-secondary);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.pdf-style-panel label {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
gap: 6px;
|
|
257
|
+
min-width: 0;
|
|
258
|
+
color: var(--text-secondary);
|
|
259
|
+
font-size: 12px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.pdf-style-panel input {
|
|
263
|
+
width: min(34vw, 360px);
|
|
264
|
+
min-width: 160px;
|
|
265
|
+
padding: 6px 8px;
|
|
266
|
+
background: var(--bg-primary);
|
|
267
|
+
border: 1px solid var(--border);
|
|
268
|
+
border-radius: 6px;
|
|
269
|
+
color: var(--text-primary);
|
|
270
|
+
font-size: 12px;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.pdf-style-panel input:focus {
|
|
274
|
+
border-color: var(--accent);
|
|
275
|
+
outline: none;
|
|
276
|
+
}
|
|
277
|
+
|
|
244
278
|
.status {
|
|
245
279
|
display: flex;
|
|
246
280
|
align-items: center;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF style preset system.
|
|
3
|
+
* Built-in styles are intentionally minimal; custom PDF styling is done by
|
|
4
|
+
* passing a CSS file path to the convert command.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const HIGHLIGHT_STYLES_DIR = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles');
|
|
13
|
+
|
|
14
|
+
const BASE_PDF_OPTIONS = {
|
|
15
|
+
format: 'A4',
|
|
16
|
+
margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} StyleConfig
|
|
21
|
+
* @property {string | null} stylesheet - Single stylesheet path for custom CSS.
|
|
22
|
+
* @property {string[]} [stylesheets] - Ordered stylesheet paths injected before md-to-pdf inline CSS.
|
|
23
|
+
* @property {object} pdfOptions - Puppeteer PDF options passed to md-to-pdf.
|
|
24
|
+
* @property {string} [highlightStyle] - highlight.js theme name used by md-to-pdf.
|
|
25
|
+
* @property {string} [css] - Inline CSS injected after md-to-pdf stylesheets.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Record<string, unknown>} PdfOptions
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @type {Record<string, StyleConfig>} */
|
|
33
|
+
export const PRESETS = {
|
|
34
|
+
default: {
|
|
35
|
+
stylesheet: null,
|
|
36
|
+
pdfOptions: BASE_PDF_OPTIONS,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a style argument to a StyleConfig.
|
|
42
|
+
* Accepts a built-in preset name or a path to a custom CSS file.
|
|
43
|
+
*
|
|
44
|
+
* @param {string | undefined} styleArg - Preset name or CSS file path
|
|
45
|
+
* @returns {Promise<StyleConfig>}
|
|
46
|
+
* @throws {Error} If a CSS file path is given but does not exist
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveStyle(styleArg) {
|
|
49
|
+
if (!styleArg) return PRESETS.default;
|
|
50
|
+
|
|
51
|
+
if (Object.hasOwn(PRESETS, styleArg)) return PRESETS[styleArg];
|
|
52
|
+
|
|
53
|
+
// Treat as a custom CSS file path
|
|
54
|
+
const cssPath = path.resolve(styleArg);
|
|
55
|
+
await fs.access(cssPath);
|
|
56
|
+
return {
|
|
57
|
+
stylesheet: cssPath,
|
|
58
|
+
stylesheets: [
|
|
59
|
+
path.join(HIGHLIGHT_STYLES_DIR, 'atom-one-dark.css'),
|
|
60
|
+
cssPath,
|
|
61
|
+
],
|
|
62
|
+
highlightStyle: 'atom-one-dark',
|
|
63
|
+
pdfOptions: BASE_PDF_OPTIONS,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a JSON file containing Puppeteer PDF options.
|
|
69
|
+
*
|
|
70
|
+
* @param {string | undefined} pdfOptionsPath - JSON file path.
|
|
71
|
+
* @param {object} baseOptions - Base options to merge into.
|
|
72
|
+
* @returns {Promise<object>} Merged PDF options.
|
|
73
|
+
* @throws {Error} If the file does not contain a JSON object.
|
|
74
|
+
*/
|
|
75
|
+
export async function resolvePdfOptions(pdfOptionsPath, baseOptions = BASE_PDF_OPTIONS) {
|
|
76
|
+
if (!pdfOptionsPath) return baseOptions;
|
|
77
|
+
|
|
78
|
+
const resolvedPath = path.resolve(pdfOptionsPath);
|
|
79
|
+
const rawOptions = await fs.readFile(resolvedPath, 'utf-8');
|
|
80
|
+
const parsedOptions = JSON.parse(rawOptions);
|
|
81
|
+
|
|
82
|
+
if (!parsedOptions || typeof parsedOptions !== 'object' || Array.isArray(parsedOptions)) {
|
|
83
|
+
throw new Error(`PDF options must be a JSON object: ${pdfOptionsPath}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
...baseOptions,
|
|
88
|
+
...parsedOptions,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* Report style — matches blue-accented business document layout */
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--blue: #1a6496;
|
|
5
|
+
--blue-light: #2980b9;
|
|
6
|
+
--border-strong: 2px solid #1a6496;
|
|
7
|
+
--border-light: 1px solid #ccc;
|
|
8
|
+
--text: #222;
|
|
9
|
+
--muted: #555;
|
|
10
|
+
--table-header-bg: #f0f4f8;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* ── Base ────────────────────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
font-family: 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif;
|
|
17
|
+
font-size: 10.5pt;
|
|
18
|
+
line-height: 1.75;
|
|
19
|
+
color: var(--text);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* ── Headings ────────────────────────────────────────────── */
|
|
23
|
+
|
|
24
|
+
/* Title page: the very first H1 */
|
|
25
|
+
h1:first-of-type {
|
|
26
|
+
font-size: 2.4em;
|
|
27
|
+
font-weight: 700;
|
|
28
|
+
color: var(--blue);
|
|
29
|
+
text-align: center;
|
|
30
|
+
border-bottom: none;
|
|
31
|
+
margin-top: 5em;
|
|
32
|
+
margin-bottom: 0.3em;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Section H1s (after the first) */
|
|
36
|
+
h1 {
|
|
37
|
+
font-size: 1.5em;
|
|
38
|
+
font-weight: 700;
|
|
39
|
+
color: var(--blue);
|
|
40
|
+
border-bottom: var(--border-strong);
|
|
41
|
+
padding-bottom: 4px;
|
|
42
|
+
margin-top: 2em;
|
|
43
|
+
margin-bottom: 0.8em;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
h2 {
|
|
47
|
+
font-size: 1.25em;
|
|
48
|
+
font-weight: 700;
|
|
49
|
+
color: var(--blue);
|
|
50
|
+
border-bottom: var(--border-light);
|
|
51
|
+
padding-bottom: 3px;
|
|
52
|
+
margin-top: 1.8em;
|
|
53
|
+
margin-bottom: 0.6em;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
h3 {
|
|
57
|
+
font-size: 1.05em;
|
|
58
|
+
font-weight: 700;
|
|
59
|
+
color: var(--blue);
|
|
60
|
+
margin-top: 1.4em;
|
|
61
|
+
margin-bottom: 0.4em;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
h4, h5, h6 {
|
|
65
|
+
color: var(--blue);
|
|
66
|
+
margin-top: 1em;
|
|
67
|
+
margin-bottom: 0.3em;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ── Body text ───────────────────────────────────────────── */
|
|
71
|
+
|
|
72
|
+
p {
|
|
73
|
+
margin: 0.5em 0;
|
|
74
|
+
padding-left: 1em;
|
|
75
|
+
text-indent: 1em;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Subtitle / date line (paragraph immediately after first H1) */
|
|
79
|
+
h1:first-of-type + p {
|
|
80
|
+
text-align: center;
|
|
81
|
+
color: var(--muted);
|
|
82
|
+
font-size: 0.95em;
|
|
83
|
+
padding-left: 0;
|
|
84
|
+
text-indent: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
li p,
|
|
88
|
+
blockquote p,
|
|
89
|
+
td p,
|
|
90
|
+
th p {
|
|
91
|
+
padding-left: 0;
|
|
92
|
+
text-indent: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── Horizontal rule ─────────────────────────────────────── */
|
|
96
|
+
|
|
97
|
+
hr {
|
|
98
|
+
border: none;
|
|
99
|
+
break-after: page;
|
|
100
|
+
margin: 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ── Links ───────────────────────────────────────────────── */
|
|
104
|
+
|
|
105
|
+
a {
|
|
106
|
+
color: var(--blue-light);
|
|
107
|
+
text-decoration: underline;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ── Tables ──────────────────────────────────────────────── */
|
|
111
|
+
|
|
112
|
+
table {
|
|
113
|
+
border-collapse: collapse;
|
|
114
|
+
width: 100%;
|
|
115
|
+
margin: 1em 0;
|
|
116
|
+
font-size: 9.5pt;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
th {
|
|
120
|
+
background: var(--table-header-bg);
|
|
121
|
+
border: 1px solid #aaa;
|
|
122
|
+
padding: 6px 10px;
|
|
123
|
+
font-weight: 700;
|
|
124
|
+
text-align: center;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
td {
|
|
128
|
+
border: 1px solid #aaa;
|
|
129
|
+
padding: 6px 10px;
|
|
130
|
+
vertical-align: top;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ── Lists ───────────────────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
ul, ol {
|
|
136
|
+
margin: 0.4em 0 0.4em 1em;
|
|
137
|
+
padding-left: 1.4em;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
li {
|
|
141
|
+
margin: 0.2em 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ── Code ────────────────────────────────────────────────── */
|
|
145
|
+
|
|
146
|
+
pre {
|
|
147
|
+
background: #282c34;
|
|
148
|
+
color: #d7dae0;
|
|
149
|
+
font-family: 'Noto Mono', monospace;
|
|
150
|
+
font-size: 8.5pt;
|
|
151
|
+
padding: 0.8em 1em;
|
|
152
|
+
line-height: 1.5;
|
|
153
|
+
overflow: auto;
|
|
154
|
+
border-radius: 4px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
pre code,
|
|
158
|
+
pre code.hljs,
|
|
159
|
+
.hljs {
|
|
160
|
+
background: #282c34;
|
|
161
|
+
color: #d7dae0;
|
|
162
|
+
font-family: 'Noto Mono', monospace;
|
|
163
|
+
font-size: 8.5pt;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
code {
|
|
167
|
+
border-radius: 2px;
|
|
168
|
+
padding: 1px 5px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pre code.hljs .hljs-section,
|
|
172
|
+
pre code.hljs .hljs-title {
|
|
173
|
+
color: #61afef;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
pre code.hljs .hljs-tag,
|
|
177
|
+
pre code.hljs .hljs-name,
|
|
178
|
+
pre code.hljs .hljs-selector-tag {
|
|
179
|
+
color: #e06c75;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pre code.hljs .hljs-string,
|
|
183
|
+
pre code.hljs .hljs-attr,
|
|
184
|
+
pre code.hljs .hljs-attribute {
|
|
185
|
+
color: #98c379;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
pre code.hljs .hljs-comment,
|
|
189
|
+
pre code.hljs .hljs-quote {
|
|
190
|
+
color: #8f98a8;
|
|
191
|
+
font-style: normal;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ── Blockquote ──────────────────────────────────────────── */
|
|
195
|
+
|
|
196
|
+
blockquote {
|
|
197
|
+
border-left: 3px solid var(--blue);
|
|
198
|
+
margin: 0.8em 0 0.8em 1em;
|
|
199
|
+
padding: 0.4em 0.8em;
|
|
200
|
+
color: var(--muted);
|
|
201
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write with permission preservation, EXDEV fallback, and
|
|
3
|
+
* O_EXCL temp creation.
|
|
4
|
+
*
|
|
5
|
+
* Caller passes `originalStat` taken from an earlier `fd.stat()` (so the
|
|
6
|
+
* permissions on the new content match the file's prior mode/uid/gid).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs/promises';
|
|
10
|
+
import { constants as fsConstants } from 'node:fs';
|
|
11
|
+
import * as crypto from 'node:crypto';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { mkError } from './errors.js';
|
|
14
|
+
|
|
15
|
+
const TMP_PREFIX = '.~mdvtmp.';
|
|
16
|
+
const PART_PREFIX = '.~mdvpart.';
|
|
17
|
+
const SWEEP_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
18
|
+
|
|
19
|
+
function isWritable(stat) {
|
|
20
|
+
// Owner-write or group-write (group/world write would already need elevated
|
|
21
|
+
// perms to even reach here). Be generous: any write bit set.
|
|
22
|
+
return (stat.mode & 0o222) !== 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Atomically write `content` to `fullPath`.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} fullPath absolute path to overwrite
|
|
29
|
+
* @param {string} content utf-8 string contents
|
|
30
|
+
* @param {fs.Stats|null} originalStat stat from prior fd.stat(); used to
|
|
31
|
+
* restore mode/uid/gid. Pass null for new files.
|
|
32
|
+
*/
|
|
33
|
+
export async function atomicWrite(fullPath, content, originalStat) {
|
|
34
|
+
if (originalStat && !isWritable(originalStat)) {
|
|
35
|
+
throw mkError('READONLY', 'file is not writable');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dir = path.dirname(fullPath);
|
|
39
|
+
const base = path.basename(fullPath);
|
|
40
|
+
const tmpPath = path.join(
|
|
41
|
+
dir,
|
|
42
|
+
`${TMP_PREFIX}${process.pid}.${crypto.randomBytes(6).toString('hex')}.${base}`
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
let tmpHandle = null;
|
|
46
|
+
let tmpExists = false;
|
|
47
|
+
try {
|
|
48
|
+
// O_EXCL: fail loudly if the random name happens to collide.
|
|
49
|
+
tmpHandle = await fs.open(
|
|
50
|
+
tmpPath,
|
|
51
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL,
|
|
52
|
+
0o600
|
|
53
|
+
);
|
|
54
|
+
tmpExists = true;
|
|
55
|
+
await tmpHandle.writeFile(content, 'utf-8');
|
|
56
|
+
|
|
57
|
+
// Restore permissions. Allow EPERM/ENOTSUP only — other failures
|
|
58
|
+
// (ENOSPC, EROFS, etc.) must surface as WRITE_FAILED.
|
|
59
|
+
if (originalStat) {
|
|
60
|
+
try {
|
|
61
|
+
await tmpHandle.chmod(originalStat.mode & 0o7777);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e.code !== 'EPERM' && e.code !== 'ENOTSUP') throw e;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await tmpHandle.chown(originalStat.uid, originalStat.gid);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e.code !== 'EPERM') throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await tmpHandle.close();
|
|
73
|
+
tmpHandle = null;
|
|
74
|
+
|
|
75
|
+
// Atomic replace. Try rename first; fall back to two-step on EXDEV.
|
|
76
|
+
try {
|
|
77
|
+
await fs.rename(tmpPath, fullPath);
|
|
78
|
+
tmpExists = false;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.code !== 'EXDEV') throw err;
|
|
81
|
+
const partPath = path.join(
|
|
82
|
+
dir,
|
|
83
|
+
`${PART_PREFIX}${crypto.randomBytes(6).toString('hex')}.${base}`
|
|
84
|
+
);
|
|
85
|
+
try {
|
|
86
|
+
await fs.copyFile(tmpPath, partPath);
|
|
87
|
+
await fs.rename(partPath, fullPath);
|
|
88
|
+
} catch (err2) {
|
|
89
|
+
try { await fs.unlink(partPath); } catch {}
|
|
90
|
+
throw err2;
|
|
91
|
+
}
|
|
92
|
+
// tmpPath is still around; cleaned up in finally.
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err && err.code && !['READONLY', 'EPERM'].includes(err.code)) {
|
|
96
|
+
// Wrap unknown failures as WRITE_FAILED so callers get a stable code.
|
|
97
|
+
const wrapped = mkError('WRITE_FAILED', err.message || String(err));
|
|
98
|
+
wrapped.cause = err;
|
|
99
|
+
throw wrapped;
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
} finally {
|
|
103
|
+
if (tmpHandle) {
|
|
104
|
+
try { await tmpHandle.close(); } catch {}
|
|
105
|
+
}
|
|
106
|
+
if (tmpExists) {
|
|
107
|
+
try { await fs.unlink(tmpPath); } catch {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sweep stale temp files left by previous (crashed) writes.
|
|
114
|
+
* Only removes files we own (uid match) and that are older than SWEEP_AGE_MS.
|
|
115
|
+
*/
|
|
116
|
+
export async function sweepStaleTemps(rootDir) {
|
|
117
|
+
let myUid;
|
|
118
|
+
try {
|
|
119
|
+
myUid = process.getuid();
|
|
120
|
+
} catch {
|
|
121
|
+
// Windows lacks getuid; sweep is best-effort there.
|
|
122
|
+
myUid = null;
|
|
123
|
+
}
|
|
124
|
+
await sweepDir(rootDir, myUid);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function sweepDir(dir, myUid) {
|
|
128
|
+
let entries;
|
|
129
|
+
try {
|
|
130
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const full = path.join(dir, entry.name);
|
|
136
|
+
if (entry.isDirectory()) {
|
|
137
|
+
// Cap recursion depth implicitly via existing watcher depth elsewhere;
|
|
138
|
+
// sweep here is shallow at rootDir to limit scan cost.
|
|
139
|
+
// Skip recursion to keep startup fast — callers can opt in if needed.
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
!entry.name.startsWith(TMP_PREFIX) &&
|
|
144
|
+
!entry.name.startsWith(PART_PREFIX)
|
|
145
|
+
) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
let st;
|
|
149
|
+
try {
|
|
150
|
+
st = await fs.lstat(full);
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const ageMs = Date.now() - st.mtimeMs;
|
|
155
|
+
if (ageMs < SWEEP_AGE_MS) continue;
|
|
156
|
+
if (myUid !== null && st.uid !== myUid) continue;
|
|
157
|
+
try { await fs.unlink(full); } catch {}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for application error codes, HTTP status mapping,
|
|
3
|
+
* and error construction.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the four duplicated `mkError` helpers and the scattered
|
|
6
|
+
* status-code logic that the audit flagged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const ERROR_STATUS = Object.freeze({
|
|
10
|
+
PATH_INVALID: 403,
|
|
11
|
+
NOT_FOUND: 404,
|
|
12
|
+
NOT_MARP: 400,
|
|
13
|
+
OUT_OF_RANGE: 400,
|
|
14
|
+
INVALID_NOTE: 400,
|
|
15
|
+
MULTI_NOTE_READONLY: 409,
|
|
16
|
+
STALE: 412,
|
|
17
|
+
IF_MATCH_REQUIRED: 428,
|
|
18
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
19
|
+
UNSUPPORTED_MEDIA_TYPE: 415,
|
|
20
|
+
ORIGIN_REJECTED: 403,
|
|
21
|
+
READONLY: 403,
|
|
22
|
+
NOT_PARSEABLE: 500,
|
|
23
|
+
WRITE_FAILED: 500,
|
|
24
|
+
READ_FAILED: 500,
|
|
25
|
+
// client-only codes (do not produce HTTP responses)
|
|
26
|
+
NETWORK_ERROR: 0,
|
|
27
|
+
DEGRADED: 0
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Construct a coded Error with optional `cause`. */
|
|
31
|
+
export function mkError(code, message, opts = {}) {
|
|
32
|
+
const err = new Error(message || code);
|
|
33
|
+
err.code = code;
|
|
34
|
+
if (opts.cause) err.cause = opts.cause;
|
|
35
|
+
if (opts.currentEtag) err.currentEtag = opts.currentEtag;
|
|
36
|
+
return err;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Send an Error as a normalized JSON response. Stack traces are NOT leaked. */
|
|
40
|
+
export function sendError(res, err) {
|
|
41
|
+
const code = err && err.code in ERROR_STATUS ? err.code : 'WRITE_FAILED';
|
|
42
|
+
const status = ERROR_STATUS[code] || 500;
|
|
43
|
+
const payload = {
|
|
44
|
+
ok: false,
|
|
45
|
+
code,
|
|
46
|
+
error: (err && err.message) || code
|
|
47
|
+
};
|
|
48
|
+
if (err && err.currentEtag) payload.currentEtag = err.currentEtag;
|
|
49
|
+
return res.status(status).json(payload);
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the file-content ETag format.
|
|
3
|
+
* `sha256:<hex>` over UTF-8 bytes of the source. Replaces the duplicated
|
|
4
|
+
* helpers previously embedded in rendering/index.js and api/marpNote.js.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
export function makeEtag(rawSource) {
|
|
10
|
+
return 'sha256:' + crypto.createHash('sha256').update(rawSource).digest('hex');
|
|
11
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 行↔バイト (JS string index) 変換ヘルパ。
|
|
3
|
+
*
|
|
4
|
+
* 規約:
|
|
5
|
+
* - BOM (U+FEFF) は rawSource[0] にそのまま残す。lineStarts[0] = 0。
|
|
6
|
+
* - 改行は LF / CRLF / CR の混在を検出。最頻種を `lineEnding` として記録し、
|
|
7
|
+
* 新規挿入行のみその改行種別を使う。既存改行は触らない。
|
|
8
|
+
* - lineStarts[i] = i 行目(0-origin)の先頭の JS string index。
|
|
9
|
+
* - markdown-it / marpit の token.map と整合: countLines が `t.map[1]` の
|
|
10
|
+
* 最大値と一致する。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Compute the string index of the start of each line (0-origin). */
|
|
14
|
+
export function computeLineStarts(source) {
|
|
15
|
+
const starts = [0];
|
|
16
|
+
for (let i = 0; i < source.length; i++) {
|
|
17
|
+
const ch = source[i];
|
|
18
|
+
if (ch === '\n') {
|
|
19
|
+
starts.push(i + 1);
|
|
20
|
+
} else if (ch === '\r') {
|
|
21
|
+
// Treat \r and \r\n as a single line break.
|
|
22
|
+
const next = i + 1;
|
|
23
|
+
if (next < source.length && source[next] === '\n') {
|
|
24
|
+
starts.push(next + 1);
|
|
25
|
+
i++;
|
|
26
|
+
} else {
|
|
27
|
+
starts.push(next);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return starts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Inspect raw source for line-ending statistics, BOM, EOF newline.
|
|
36
|
+
* @param {string} source
|
|
37
|
+
* @returns {{
|
|
38
|
+
* lineEnding: '\n' | '\r\n' | '\r',
|
|
39
|
+
* hasBom: boolean,
|
|
40
|
+
* endsWithNewline: boolean,
|
|
41
|
+
* lineStarts: number[]
|
|
42
|
+
* }}
|
|
43
|
+
*/
|
|
44
|
+
export function analyseSource(source) {
|
|
45
|
+
const hasBom = source.charCodeAt(0) === 0xFEFF;
|
|
46
|
+
const crlf = (source.match(/\r\n/g) || []).length;
|
|
47
|
+
const lf = (source.match(/(?<!\r)\n/g) || []).length;
|
|
48
|
+
const cr = (source.match(/\r(?!\n)/g) || []).length;
|
|
49
|
+
let lineEnding = '\n';
|
|
50
|
+
if (crlf > lf && crlf > cr) lineEnding = '\r\n';
|
|
51
|
+
else if (cr > lf && cr > crlf) lineEnding = '\r';
|
|
52
|
+
const endsWithNewline = /(?:\r\n|\r|\n)$/.test(source);
|
|
53
|
+
const lineStarts = computeLineStarts(source);
|
|
54
|
+
return { lineEnding, hasBom, endsWithNewline, lineStarts };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert a line index to a string index. Throws if line is out of range.
|
|
59
|
+
* @param {number[]} lineStarts
|
|
60
|
+
* @param {number} line 0-origin line number
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
export function lineToOffset(lineStarts, line) {
|
|
64
|
+
if (line < 0) throw new Error(`line out of range: ${line}`);
|
|
65
|
+
if (line < lineStarts.length) return lineStarts[line];
|
|
66
|
+
// For one-past-the-end (== total lines), return source length.
|
|
67
|
+
// Caller (e.g. last slide endLine) should pass `source.length` separately.
|
|
68
|
+
throw new Error(`line out of range: ${line} (max ${lineStarts.length - 1})`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert [startLine, endLine) half-open line range to [startOffset, endOffset).
|
|
73
|
+
* `endLine` may equal `totalLines` to mean "to end of source".
|
|
74
|
+
*
|
|
75
|
+
* @param {number[]} lineStarts
|
|
76
|
+
* @param {number} totalLines number of lines in the source
|
|
77
|
+
* @param {number} sourceLength
|
|
78
|
+
* @param {number} startLine
|
|
79
|
+
* @param {number} endLine
|
|
80
|
+
* @returns {{startOffset: number, endOffset: number}}
|
|
81
|
+
*/
|
|
82
|
+
export function lineRangeToOffsets(lineStarts, totalLines, sourceLength, startLine, endLine) {
|
|
83
|
+
const startOffset = lineToOffset(lineStarts, startLine);
|
|
84
|
+
const endOffset = endLine >= totalLines ? sourceLength : lineToOffset(lineStarts, endLine);
|
|
85
|
+
return { startOffset, endOffset };
|
|
86
|
+
}
|