@grainulation/mill 1.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/mill.js +320 -0
- package/lib/exporters/csv.js +83 -0
- package/lib/exporters/json-ld.js +44 -0
- package/lib/exporters/markdown.js +116 -0
- package/lib/exporters/pdf.js +104 -0
- package/lib/formats/bibtex.js +76 -0
- package/lib/formats/changelog.js +102 -0
- package/lib/formats/csv.js +92 -0
- package/lib/formats/dot.js +129 -0
- package/lib/formats/evidence-matrix.js +87 -0
- package/lib/formats/executive-summary.js +130 -0
- package/lib/formats/github-issues.js +89 -0
- package/lib/formats/graphml.js +118 -0
- package/lib/formats/html-report.js +181 -0
- package/lib/formats/jira-csv.js +89 -0
- package/lib/formats/json-ld.js +28 -0
- package/lib/formats/markdown.js +118 -0
- package/lib/formats/ndjson.js +25 -0
- package/lib/formats/obsidian.js +136 -0
- package/lib/formats/opml.js +108 -0
- package/lib/formats/ris.js +70 -0
- package/lib/formats/rss.js +100 -0
- package/lib/formats/sankey.js +72 -0
- package/lib/formats/slide-deck.js +200 -0
- package/lib/formats/sql.js +116 -0
- package/lib/formats/static-site.js +169 -0
- package/lib/formats/treemap.js +65 -0
- package/lib/formats/typescript-defs.js +147 -0
- package/lib/formats/yaml.js +144 -0
- package/lib/formats.js +60 -0
- package/lib/index.js +14 -0
- package/lib/json-ld-common.js +72 -0
- package/lib/publishers/clipboard.js +70 -0
- package/lib/publishers/static.js +152 -0
- package/lib/serve-mcp.js +340 -0
- package/lib/server.js +535 -0
- package/package.json +53 -0
- package/public/grainulation-tokens.css +321 -0
- package/public/index.html +891 -0
package/lib/server.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mill serve — local HTTP server for the mill export workbench UI
|
|
4
|
+
*
|
|
5
|
+
* Live preview of artifact exports, SSE for file-change updates,
|
|
6
|
+
* REST API for format listing, export execution, and job history.
|
|
7
|
+
* Zero npm dependencies (node:http only).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* mill serve [--port 9094] [--source /path/to/sprint]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
15
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
16
|
+
import { join, resolve, extname, dirname } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
// ── Crash handlers ──
|
|
23
|
+
process.on('uncaughtException', (err) => {
|
|
24
|
+
process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
process.on('unhandledRejection', (reason) => {
|
|
28
|
+
process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
|
32
|
+
|
|
33
|
+
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
function arg(name, fallback) {
|
|
37
|
+
const i = args.indexOf(`--${name}`);
|
|
38
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PORT = parseInt(arg('port', '9094'), 10);
|
|
42
|
+
const SOURCE = resolve(arg('source', process.cwd()));
|
|
43
|
+
const CORS_ORIGIN = arg('cors', null);
|
|
44
|
+
|
|
45
|
+
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
48
|
+
function vlog(...a) {
|
|
49
|
+
if (!verbose) return;
|
|
50
|
+
const ts = new Date().toISOString();
|
|
51
|
+
process.stderr.write(`[${ts}] mill: ${a.join(' ')}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const ROUTES = [
|
|
57
|
+
{ method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
|
|
58
|
+
{ method: 'GET', path: '/api/state', description: 'Current state (source, formats, history)' },
|
|
59
|
+
{ method: 'GET', path: '/api/formats', description: 'List available export formats' },
|
|
60
|
+
{ method: 'POST', path: '/api/export', description: 'Export claims to a target format' },
|
|
61
|
+
{ method: 'GET', path: '/api/preview', description: 'Preview export output by ?format' },
|
|
62
|
+
{ method: 'GET', path: '/api/download', description: 'Download exported file by ?format' },
|
|
63
|
+
{ method: 'GET', path: '/api/history', description: 'Export job history' },
|
|
64
|
+
{ method: 'POST', path: '/api/refresh', description: 'Reload source data from disk' },
|
|
65
|
+
{ method: 'GET', path: '/api/docs', description: 'This API documentation page' },
|
|
66
|
+
{ method: 'GET', path: '/health', description: 'Health check endpoint' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// ── Format modules ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const formats = {};
|
|
72
|
+
const formatModules = [
|
|
73
|
+
'markdown', 'csv', 'json-ld',
|
|
74
|
+
'html-report', 'executive-summary', 'slide-deck', 'ndjson',
|
|
75
|
+
'typescript-defs', 'yaml', 'sql', 'evidence-matrix',
|
|
76
|
+
'bibtex', 'ris', 'changelog', 'rss',
|
|
77
|
+
'jira-csv', 'github-issues', 'opml', 'obsidian',
|
|
78
|
+
'graphml', 'dot', 'treemap', 'sankey', 'static-site',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const mod of formatModules) {
|
|
82
|
+
try {
|
|
83
|
+
const m = await import(`./formats/${mod}.js`);
|
|
84
|
+
formats[m.name] = {
|
|
85
|
+
name: m.name,
|
|
86
|
+
extension: m.extension,
|
|
87
|
+
mimeType: m.mimeType,
|
|
88
|
+
description: m.description,
|
|
89
|
+
convert: m.convert,
|
|
90
|
+
};
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.error(`mill: failed to load format "${mod}": ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const exportHistory = [];
|
|
99
|
+
let compilation = null;
|
|
100
|
+
let claims = null;
|
|
101
|
+
let sourceFiles = {};
|
|
102
|
+
|
|
103
|
+
const sseClients = new Set();
|
|
104
|
+
|
|
105
|
+
function broadcast(event) {
|
|
106
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
107
|
+
for (const res of sseClients) {
|
|
108
|
+
try { res.write(data); } catch { sseClients.delete(res); }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Source loading ────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async function loadSource() {
|
|
115
|
+
const files = {};
|
|
116
|
+
vlog('read', `loading source from ${SOURCE}`);
|
|
117
|
+
|
|
118
|
+
// Look for compilation.json
|
|
119
|
+
const compilationPath = join(SOURCE, 'compilation.json');
|
|
120
|
+
if (existsSync(compilationPath)) {
|
|
121
|
+
try {
|
|
122
|
+
compilation = JSON.parse(await readFile(compilationPath, 'utf8'));
|
|
123
|
+
const s = await stat(compilationPath);
|
|
124
|
+
files['compilation.json'] = {
|
|
125
|
+
path: compilationPath,
|
|
126
|
+
size: s.size,
|
|
127
|
+
modified: s.mtime.toISOString(),
|
|
128
|
+
};
|
|
129
|
+
} catch (e) { compilation = null; vlog('warn', `failed to parse compilation.json: ${e.message}`); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Look for claims.json
|
|
133
|
+
const claimsPath = join(SOURCE, 'claims.json');
|
|
134
|
+
if (existsSync(claimsPath)) {
|
|
135
|
+
try {
|
|
136
|
+
const raw = JSON.parse(await readFile(claimsPath, 'utf8'));
|
|
137
|
+
claims = Array.isArray(raw) ? raw : (raw.claims || []);
|
|
138
|
+
const s = await stat(claimsPath);
|
|
139
|
+
files['claims.json'] = {
|
|
140
|
+
path: claimsPath,
|
|
141
|
+
size: s.size,
|
|
142
|
+
modified: s.mtime.toISOString(),
|
|
143
|
+
};
|
|
144
|
+
} catch (e) { claims = null; vlog('warn', `failed to parse claims.json: ${e.message}`); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If compilation.json is the wheat compiler output (no .claims key),
|
|
148
|
+
// or if there's no compilation at all, synthesize from claims.json
|
|
149
|
+
if (claims) {
|
|
150
|
+
const compilationHasClaims = compilation && Array.isArray(compilation.claims);
|
|
151
|
+
if (!compilationHasClaims) {
|
|
152
|
+
// Extract meta from claims.json if present
|
|
153
|
+
const claimsRaw = JSON.parse(await readFile(join(SOURCE, 'claims.json'), 'utf8'));
|
|
154
|
+
const meta = claimsRaw.meta || {};
|
|
155
|
+
|
|
156
|
+
// Merge compiler certificate if available
|
|
157
|
+
const cert = compilation ? {
|
|
158
|
+
compiled_at: compilation.compiled_at,
|
|
159
|
+
sha256: compilation.claims_hash,
|
|
160
|
+
claim_count: claims.length,
|
|
161
|
+
compiler_version: compilation.compiler_version,
|
|
162
|
+
status: compilation.status,
|
|
163
|
+
} : {};
|
|
164
|
+
|
|
165
|
+
compilation = {
|
|
166
|
+
meta,
|
|
167
|
+
claims,
|
|
168
|
+
conflicts: compilation?.conflict_graph || [],
|
|
169
|
+
certificate: cert,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
sourceFiles = files;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildState() {
|
|
178
|
+
return {
|
|
179
|
+
source: SOURCE,
|
|
180
|
+
sourceFiles,
|
|
181
|
+
formats: Object.values(formats).map(f => ({
|
|
182
|
+
name: f.name,
|
|
183
|
+
extension: f.extension,
|
|
184
|
+
mimeType: f.mimeType,
|
|
185
|
+
description: f.description,
|
|
186
|
+
})),
|
|
187
|
+
hasCompilation: compilation !== null,
|
|
188
|
+
claimCount: compilation?.claims?.length || 0,
|
|
189
|
+
historyCount: exportHistory.length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Export execution ──────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function runExport(formatName, options = {}) {
|
|
196
|
+
const fmt = formats[formatName];
|
|
197
|
+
if (!fmt) {
|
|
198
|
+
return { error: `Unknown format: ${formatName}` };
|
|
199
|
+
}
|
|
200
|
+
if (!compilation) {
|
|
201
|
+
return { error: 'No compilation data available. Ensure compilation.json or claims.json exists in the source directory.' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const startTime = Date.now();
|
|
206
|
+
const output = fmt.convert(compilation);
|
|
207
|
+
const duration = Date.now() - startTime;
|
|
208
|
+
|
|
209
|
+
const job = {
|
|
210
|
+
id: randomUUID().slice(0, 8),
|
|
211
|
+
format: formatName,
|
|
212
|
+
extension: fmt.extension,
|
|
213
|
+
mimeType: fmt.mimeType,
|
|
214
|
+
claimCount: compilation.claims?.length || 0,
|
|
215
|
+
outputSize: Buffer.byteLength(output, 'utf8'),
|
|
216
|
+
duration,
|
|
217
|
+
timestamp: new Date().toISOString(),
|
|
218
|
+
options,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
exportHistory.unshift(job);
|
|
222
|
+
// Keep last 50 jobs
|
|
223
|
+
if (exportHistory.length > 50) exportHistory.length = 50;
|
|
224
|
+
|
|
225
|
+
broadcast({ type: 'export-complete', data: job });
|
|
226
|
+
|
|
227
|
+
return { job, output };
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return { error: `Export failed: ${err.message}` };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const MIME = {
|
|
236
|
+
'.html': 'text/html; charset=utf-8',
|
|
237
|
+
'.css': 'text/css; charset=utf-8',
|
|
238
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
239
|
+
'.json': 'application/json; charset=utf-8',
|
|
240
|
+
'.svg': 'image/svg+xml',
|
|
241
|
+
'.png': 'image/png',
|
|
242
|
+
'.ico': 'image/x-icon',
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// ── Body parser ───────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
function readBody(req) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const chunks = [];
|
|
250
|
+
let size = 0;
|
|
251
|
+
req.on('data', chunk => {
|
|
252
|
+
size += chunk.length;
|
|
253
|
+
if (size > 1048576) { resolve(null); req.destroy(); return; }
|
|
254
|
+
chunks.push(chunk);
|
|
255
|
+
});
|
|
256
|
+
req.on('end', () => {
|
|
257
|
+
try {
|
|
258
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
259
|
+
} catch {
|
|
260
|
+
resolve(null);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
req.on('error', reject);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const server = createServer(async (req, res) => {
|
|
270
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
271
|
+
|
|
272
|
+
// CORS (only when --cors is passed)
|
|
273
|
+
if (CORS_ORIGIN) {
|
|
274
|
+
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
275
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
276
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (req.method === 'OPTIONS' && CORS_ORIGIN) {
|
|
280
|
+
res.writeHead(204);
|
|
281
|
+
res.end();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
vlog('request', req.method, url.pathname);
|
|
286
|
+
|
|
287
|
+
// ── Health check ──
|
|
288
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
289
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), formats: Object.keys(formats).length }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── API: docs ──
|
|
295
|
+
if (req.method === 'GET' && url.pathname === '/api/docs') {
|
|
296
|
+
const html = `<!DOCTYPE html><html><head><title>mill API</title>
|
|
297
|
+
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
298
|
+
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
299
|
+
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
300
|
+
<body><h1>mill API</h1><p>${ROUTES.length} endpoints</p>
|
|
301
|
+
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
302
|
+
${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
|
|
303
|
+
</table></body></html>`;
|
|
304
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
305
|
+
res.end(html);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── SSE endpoint ──
|
|
310
|
+
if (req.method === 'GET' && url.pathname === '/events') {
|
|
311
|
+
res.writeHead(200, {
|
|
312
|
+
'Content-Type': 'text/event-stream',
|
|
313
|
+
'Cache-Control': 'no-cache',
|
|
314
|
+
'Connection': 'keep-alive',
|
|
315
|
+
});
|
|
316
|
+
res.write(`data: ${JSON.stringify({ type: 'state', data: buildState() })}\n\n`);
|
|
317
|
+
const heartbeat = setInterval(() => {
|
|
318
|
+
try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
|
|
319
|
+
}, 15000);
|
|
320
|
+
sseClients.add(res);
|
|
321
|
+
vlog('sse', `client connected (${sseClients.size} total)`);
|
|
322
|
+
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── API: state ──
|
|
327
|
+
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
328
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
329
|
+
res.end(JSON.stringify(buildState()));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── API: formats ──
|
|
334
|
+
if (req.method === 'GET' && url.pathname === '/api/formats') {
|
|
335
|
+
const formatList = Object.values(formats).map(f => ({
|
|
336
|
+
name: f.name,
|
|
337
|
+
extension: f.extension,
|
|
338
|
+
mimeType: f.mimeType,
|
|
339
|
+
description: f.description,
|
|
340
|
+
schema_version: '1.0.0',
|
|
341
|
+
}));
|
|
342
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
343
|
+
res.end(JSON.stringify({ formats: formatList }));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── API: export ──
|
|
348
|
+
if (req.method === 'POST' && url.pathname === '/api/export') {
|
|
349
|
+
const body = await readBody(req);
|
|
350
|
+
if (!body || !body.format) {
|
|
351
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
352
|
+
res.end(JSON.stringify({ error: 'Missing "format" in request body' }));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const result = runExport(body.format, body.options || {});
|
|
357
|
+
if (result.error) {
|
|
358
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
359
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
364
|
+
res.end(JSON.stringify({
|
|
365
|
+
job: result.job,
|
|
366
|
+
output: result.output,
|
|
367
|
+
}));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── API: preview (GET with query param) ──
|
|
372
|
+
if (req.method === 'GET' && url.pathname === '/api/preview') {
|
|
373
|
+
const formatName = url.searchParams.get('format');
|
|
374
|
+
if (!formatName) {
|
|
375
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
376
|
+
res.end(JSON.stringify({ error: 'Missing "format" query parameter' }));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const result = runExport(formatName);
|
|
381
|
+
if (result.error) {
|
|
382
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
383
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
388
|
+
res.end(JSON.stringify({
|
|
389
|
+
format: formatName,
|
|
390
|
+
output: result.output,
|
|
391
|
+
size: result.job.outputSize,
|
|
392
|
+
}));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── API: download (returns the raw file) ──
|
|
397
|
+
if (req.method === 'GET' && url.pathname === '/api/download') {
|
|
398
|
+
const formatName = url.searchParams.get('format');
|
|
399
|
+
if (!formatName) {
|
|
400
|
+
res.writeHead(400);
|
|
401
|
+
res.end('Missing format');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const fmt = formats[formatName];
|
|
406
|
+
if (!fmt) {
|
|
407
|
+
res.writeHead(400);
|
|
408
|
+
res.end('Unknown format');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const result = runExport(formatName);
|
|
413
|
+
if (result.error) {
|
|
414
|
+
res.writeHead(400);
|
|
415
|
+
res.end(result.error);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const filename = `export${fmt.extension}`;
|
|
420
|
+
res.writeHead(200, {
|
|
421
|
+
'Content-Type': fmt.mimeType,
|
|
422
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
423
|
+
});
|
|
424
|
+
res.end(result.output);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── API: history ──
|
|
429
|
+
if (req.method === 'GET' && url.pathname === '/api/history') {
|
|
430
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
431
|
+
res.end(JSON.stringify({ history: exportHistory }));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── API: refresh ──
|
|
436
|
+
if (req.method === 'POST' && url.pathname === '/api/refresh') {
|
|
437
|
+
await loadSource();
|
|
438
|
+
broadcast({ type: 'state', data: buildState() });
|
|
439
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
440
|
+
res.end(JSON.stringify(buildState()));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Static files ──
|
|
445
|
+
let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
446
|
+
|
|
447
|
+
// Prevent directory traversal
|
|
448
|
+
const resolved = resolve(PUBLIC_DIR, '.' + filePath);
|
|
449
|
+
if (!resolved.startsWith(PUBLIC_DIR)) {
|
|
450
|
+
res.writeHead(403);
|
|
451
|
+
res.end('forbidden');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (existsSync(resolved) && statSync(resolved).isFile()) {
|
|
456
|
+
const ext = extname(resolved);
|
|
457
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
458
|
+
res.end(readFileSync(resolved));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
res.writeHead(404);
|
|
463
|
+
res.end('not found');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ── File watching (fingerprint-based polling) ─────────────────────────────────
|
|
467
|
+
|
|
468
|
+
let lastFingerprint = '';
|
|
469
|
+
function computeFingerprint() {
|
|
470
|
+
const names = ['compilation.json', 'claims.json'];
|
|
471
|
+
const parts = [];
|
|
472
|
+
for (const name of names) {
|
|
473
|
+
const fp = join(SOURCE, name);
|
|
474
|
+
try { const s = statSync(fp); parts.push(name + ':' + s.mtimeMs); } catch { /* skip */ }
|
|
475
|
+
}
|
|
476
|
+
return parts.join('|');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function startWatcher() {
|
|
480
|
+
lastFingerprint = computeFingerprint();
|
|
481
|
+
setInterval(() => {
|
|
482
|
+
const fp = computeFingerprint();
|
|
483
|
+
if (fp !== lastFingerprint) {
|
|
484
|
+
lastFingerprint = fp;
|
|
485
|
+
loadSource();
|
|
486
|
+
broadcast({ type: 'source-changed', data: buildState() });
|
|
487
|
+
}
|
|
488
|
+
}, 2000);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
492
|
+
const shutdown = (signal) => {
|
|
493
|
+
console.log(`\nmill: ${signal} received, shutting down...`);
|
|
494
|
+
for (const res of sseClients) { try { res.end(); } catch {} }
|
|
495
|
+
sseClients.clear();
|
|
496
|
+
server.close(() => process.exit(0));
|
|
497
|
+
setTimeout(() => process.exit(1), 5000);
|
|
498
|
+
};
|
|
499
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
500
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
501
|
+
|
|
502
|
+
server.on('error', (err) => {
|
|
503
|
+
if (err.code === 'EADDRINUSE') {
|
|
504
|
+
console.error(`mill: port ${PORT} is already in use. Try --port <other>.`);
|
|
505
|
+
} else if (err.code === 'EACCES') {
|
|
506
|
+
console.error(`mill: port ${PORT} requires elevated privileges.`);
|
|
507
|
+
} else {
|
|
508
|
+
console.error(`mill: server error: ${err.message}`);
|
|
509
|
+
}
|
|
510
|
+
process.exit(1);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
if (!existsSync(SOURCE)) {
|
|
516
|
+
console.error(`mill: source directory not found: ${SOURCE}`);
|
|
517
|
+
console.error(' Use --source <dir> to specify the sprint directory.');
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
await loadSource();
|
|
522
|
+
startWatcher();
|
|
523
|
+
|
|
524
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
525
|
+
vlog('listen', `port=${PORT}`, `source=${SOURCE}`);
|
|
526
|
+
console.log(`mill: serving on http://localhost:${PORT}`);
|
|
527
|
+
console.log(` source: ${SOURCE}`);
|
|
528
|
+
console.log(` formats: ${Object.keys(formats).join(', ')}`);
|
|
529
|
+
if (compilation) {
|
|
530
|
+
console.log(` claims: ${compilation.claims?.length || 0}`);
|
|
531
|
+
} else {
|
|
532
|
+
console.log(` claims: no compilation data found`);
|
|
533
|
+
}
|
|
534
|
+
console.log(` files: ${Object.keys(sourceFiles).join(', ') || 'none detected'}`);
|
|
535
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grainulation/mill",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Turn wheat sprint artifacts into shareable formats",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mill": "bin/mill.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./lib/index.js",
|
|
11
|
+
"./server": {
|
|
12
|
+
"import": "./lib/server.js"
|
|
13
|
+
},
|
|
14
|
+
"./formats/markdown": "./lib/formats/markdown.js",
|
|
15
|
+
"./formats/csv": "./lib/formats/csv.js",
|
|
16
|
+
"./formats/json-ld": "./lib/formats/json-ld.js",
|
|
17
|
+
"./tokens": "./public/grainulation-tokens.css"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/",
|
|
21
|
+
"lib/",
|
|
22
|
+
"public/",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node test/basic.test.js",
|
|
27
|
+
"start": "node bin/mill.js",
|
|
28
|
+
"serve": "node bin/mill.js serve"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"wheat",
|
|
32
|
+
"sprint",
|
|
33
|
+
"export",
|
|
34
|
+
"pdf",
|
|
35
|
+
"csv",
|
|
36
|
+
"markdown",
|
|
37
|
+
"json-ld",
|
|
38
|
+
"publishing"
|
|
39
|
+
],
|
|
40
|
+
"author": "grainulation contributors",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/grainulation/mill.git"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/grainulation/mill/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://mill.grainulation.com"
|
|
53
|
+
}
|