@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/bin/mill.js +320 -0
  5. package/lib/exporters/csv.js +83 -0
  6. package/lib/exporters/json-ld.js +44 -0
  7. package/lib/exporters/markdown.js +116 -0
  8. package/lib/exporters/pdf.js +104 -0
  9. package/lib/formats/bibtex.js +76 -0
  10. package/lib/formats/changelog.js +102 -0
  11. package/lib/formats/csv.js +92 -0
  12. package/lib/formats/dot.js +129 -0
  13. package/lib/formats/evidence-matrix.js +87 -0
  14. package/lib/formats/executive-summary.js +130 -0
  15. package/lib/formats/github-issues.js +89 -0
  16. package/lib/formats/graphml.js +118 -0
  17. package/lib/formats/html-report.js +181 -0
  18. package/lib/formats/jira-csv.js +89 -0
  19. package/lib/formats/json-ld.js +28 -0
  20. package/lib/formats/markdown.js +118 -0
  21. package/lib/formats/ndjson.js +25 -0
  22. package/lib/formats/obsidian.js +136 -0
  23. package/lib/formats/opml.js +108 -0
  24. package/lib/formats/ris.js +70 -0
  25. package/lib/formats/rss.js +100 -0
  26. package/lib/formats/sankey.js +72 -0
  27. package/lib/formats/slide-deck.js +200 -0
  28. package/lib/formats/sql.js +116 -0
  29. package/lib/formats/static-site.js +169 -0
  30. package/lib/formats/treemap.js +65 -0
  31. package/lib/formats/typescript-defs.js +147 -0
  32. package/lib/formats/yaml.js +144 -0
  33. package/lib/formats.js +60 -0
  34. package/lib/index.js +14 -0
  35. package/lib/json-ld-common.js +72 -0
  36. package/lib/publishers/clipboard.js +70 -0
  37. package/lib/publishers/static.js +152 -0
  38. package/lib/serve-mcp.js +340 -0
  39. package/lib/server.js +535 -0
  40. package/package.json +53 -0
  41. package/public/grainulation-tokens.css +321 -0
  42. package/public/index.html +891 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * mill format: yaml
3
+ *
4
+ * Converts compilation.json to YAML output.
5
+ * Simple JSON-to-YAML serializer — handles objects, arrays, strings,
6
+ * numbers, booleans, and nulls. No external dependencies.
7
+ * Zero dependencies — node built-in only.
8
+ */
9
+
10
+ export const name = 'yaml';
11
+ export const extension = '.yaml';
12
+ export const mimeType = 'text/yaml; charset=utf-8';
13
+ export const description = 'YAML serialization of compilation data';
14
+
15
+ /**
16
+ * Convert a compilation object to YAML.
17
+ * @param {object} compilation - The compilation.json content
18
+ * @returns {string} YAML output
19
+ */
20
+ export function convert(compilation) {
21
+ const lines = [];
22
+ lines.push('# Auto-generated by mill yaml format');
23
+ lines.push('');
24
+ lines.push(toYaml(compilation, 0));
25
+ return lines.join('\n');
26
+ }
27
+
28
+ function toYaml(value, indent) {
29
+ if (value === null || value === undefined) {
30
+ return 'null';
31
+ }
32
+ if (typeof value === 'boolean') {
33
+ return value ? 'true' : 'false';
34
+ }
35
+ if (typeof value === 'number') {
36
+ return String(value);
37
+ }
38
+ if (typeof value === 'string') {
39
+ return yamlString(value, indent);
40
+ }
41
+ if (Array.isArray(value)) {
42
+ return yamlArray(value, indent);
43
+ }
44
+ if (typeof value === 'object') {
45
+ return yamlObject(value, indent);
46
+ }
47
+ return String(value);
48
+ }
49
+
50
+ function yamlString(str, indent) {
51
+ // Use block scalar for multi-line strings
52
+ if (str.includes('\n')) {
53
+ const pad = ' '.repeat(indent + 2);
54
+ const indented = str.split('\n').map(line => pad + line).join('\n');
55
+ return '|\n' + indented;
56
+ }
57
+ // Quote if the string contains special YAML characters or could be misinterpreted
58
+ if (needsQuoting(str)) {
59
+ return "'" + str.replace(/'/g, "''") + "'";
60
+ }
61
+ return str;
62
+ }
63
+
64
+ function needsQuoting(str) {
65
+ if (str === '') return true;
66
+ if (str === 'true' || str === 'false' || str === 'null' || str === 'yes' || str === 'no') return true;
67
+ if (/^[0-9]/.test(str) && !isNaN(Number(str))) return true;
68
+ if (/[:{}\[\],&*?|>!%#@`"']/.test(str)) return true;
69
+ if (/^\s|\s$/.test(str)) return true;
70
+ if (str.startsWith('- ') || str.startsWith('? ')) return true;
71
+ return false;
72
+ }
73
+
74
+ function yamlArray(arr, indent) {
75
+ if (arr.length === 0) return '[]';
76
+ const pad = ' '.repeat(indent);
77
+ const items = [];
78
+ for (const item of arr) {
79
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
80
+ // Object items: first key on same line as -, rest indented
81
+ const keys = Object.keys(item);
82
+ if (keys.length === 0) {
83
+ items.push(pad + '- {}');
84
+ } else {
85
+ const firstKey = keys[0];
86
+ const firstVal = toYaml(item[firstKey], indent + 4);
87
+ const firstLine = isScalar(item[firstKey])
88
+ ? `${pad}- ${firstKey}: ${firstVal}`
89
+ : `${pad}- ${firstKey}:\n${indentBlock(firstVal, indent + 4)}`;
90
+ const rest = keys.slice(1).map(key => {
91
+ const val = toYaml(item[key], indent + 4);
92
+ if (isScalar(item[key])) {
93
+ return `${pad} ${key}: ${val}`;
94
+ }
95
+ return `${pad} ${key}:\n${indentBlock(val, indent + 4)}`;
96
+ });
97
+ items.push([firstLine, ...rest].join('\n'));
98
+ }
99
+ } else if (Array.isArray(item)) {
100
+ const nested = toYaml(item, indent + 2);
101
+ items.push(`${pad}-\n${indentBlock(nested, indent + 2)}`);
102
+ } else {
103
+ items.push(`${pad}- ${toYaml(item, indent + 2)}`);
104
+ }
105
+ }
106
+ return '\n' + items.join('\n');
107
+ }
108
+
109
+ function yamlObject(obj, indent) {
110
+ const keys = Object.keys(obj);
111
+ if (keys.length === 0) return '{}';
112
+ const pad = ' '.repeat(indent);
113
+ const entries = [];
114
+ for (const key of keys) {
115
+ const val = obj[key];
116
+ const yamlKey = needsQuoting(key) ? `'${key.replace(/'/g, "''")}'` : key;
117
+ if (isScalar(val)) {
118
+ entries.push(`${pad}${yamlKey}: ${toYaml(val, indent + 2)}`);
119
+ } else {
120
+ const nested = toYaml(val, indent + 2);
121
+ if (nested.startsWith('\n')) {
122
+ entries.push(`${pad}${yamlKey}:${nested}`);
123
+ } else {
124
+ entries.push(`${pad}${yamlKey}:\n${indentBlock(nested, indent + 2)}`);
125
+ }
126
+ }
127
+ }
128
+ return '\n' + entries.join('\n');
129
+ }
130
+
131
+ function isScalar(value) {
132
+ return value === null || value === undefined ||
133
+ typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
134
+ }
135
+
136
+ function indentBlock(text, indent) {
137
+ const pad = ' '.repeat(indent);
138
+ return text.split('\n').map(line => {
139
+ if (line.trim() === '') return '';
140
+ // Only add padding if the line doesn't already have it
141
+ if (line.startsWith(pad)) return line;
142
+ return pad + line;
143
+ }).join('\n');
144
+ }
package/lib/formats.js ADDED
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const pdf = require('./exporters/pdf.js');
4
+ const csv = require('./exporters/csv.js');
5
+ const markdown = require('./exporters/markdown.js');
6
+ const jsonLd = require('./exporters/json-ld.js');
7
+ const static_ = require('./publishers/static.js');
8
+ const clipboard = require('./publishers/clipboard.js');
9
+
10
+ const EXPORTERS = {
11
+ pdf,
12
+ csv,
13
+ markdown,
14
+ 'json-ld': jsonLd,
15
+ };
16
+
17
+ const PUBLISHERS = {
18
+ static: static_,
19
+ clipboard,
20
+ };
21
+
22
+ /**
23
+ * Detect the likely format of an input file by extension.
24
+ */
25
+ function detectFormat(filePath) {
26
+ const ext = filePath.split('.').pop().toLowerCase();
27
+ const map = {
28
+ html: 'html',
29
+ htm: 'html',
30
+ md: 'markdown',
31
+ json: 'json',
32
+ csv: 'csv',
33
+ jsonld: 'json-ld',
34
+ };
35
+ return map[ext] || 'unknown';
36
+ }
37
+
38
+ function getExporter(name) {
39
+ return EXPORTERS[name] || null;
40
+ }
41
+
42
+ function getPublisher(name) {
43
+ return PUBLISHERS[name] || null;
44
+ }
45
+
46
+ function listExportFormats() {
47
+ return Object.keys(EXPORTERS);
48
+ }
49
+
50
+ function listPublishTargets() {
51
+ return Object.keys(PUBLISHERS);
52
+ }
53
+
54
+ module.exports = {
55
+ detectFormat,
56
+ getExporter,
57
+ getPublisher,
58
+ listExportFormats,
59
+ listPublishTargets,
60
+ };
package/lib/index.js ADDED
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ const formats = require('./formats.js');
4
+
5
+ const name = 'mill';
6
+ const version = require('../package.json').version;
7
+ const description = 'Turn wheat sprint artifacts into shareable formats';
8
+
9
+ module.exports = {
10
+ name,
11
+ version,
12
+ description,
13
+ ...formats,
14
+ };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * json-ld-common.js — Shared JSON-LD vocabulary for mill
5
+ *
6
+ * Both the ESM format (formats/json-ld.js) and the CJS exporter
7
+ * (exporters/json-ld.js) use the same schema.org/Report structure.
8
+ * This module is the single source of truth for that vocabulary.
9
+ *
10
+ * CJS module so both CJS exporters and ESM formats can consume it.
11
+ */
12
+
13
+ const CONTEXT = {
14
+ '@vocab': 'https://schema.org/',
15
+ wheat: 'https://grainulation.com/ns/wheat#',
16
+ claim: 'wheat:Claim',
17
+ confidence: 'wheat:confidence',
18
+ evidenceTier: 'wheat:evidenceTier',
19
+ claimType: 'wheat:claimType',
20
+ sprintId: 'wheat:sprintId',
21
+ };
22
+
23
+ function claimToJsonLd(claim) {
24
+ const body = claim.content || claim.text || '';
25
+ const evidenceTier = typeof claim.evidence === 'string'
26
+ ? claim.evidence
27
+ : (claim.evidence?.tier ?? claim.evidence_tier ?? null);
28
+
29
+ return {
30
+ '@type': 'claim',
31
+ '@id': `wheat:claim/${claim.id}`,
32
+ identifier: claim.id,
33
+ claimType: claim.type,
34
+ text: body,
35
+ description: body,
36
+ confidence: claim.confidence ?? null,
37
+ evidenceTier,
38
+ dateCreated: claim.created || claim.timestamp || null,
39
+ ...(claim.tags?.length ? { keywords: claim.tags.join(', ') } : {}),
40
+ ...(claim.status ? { status: claim.status } : {}),
41
+ };
42
+ }
43
+
44
+ function buildReport(meta, claims, certificate) {
45
+ return {
46
+ '@context': CONTEXT,
47
+ '@type': 'Report',
48
+ '@id': `wheat:sprint/${meta.sprint || 'unknown'}`,
49
+ name: meta.sprint || meta.question || 'Wheat Sprint Report',
50
+ description: meta.question || '',
51
+ dateCreated: (certificate && certificate.compiled_at) || new Date().toISOString(),
52
+ ...(meta.audience ? { audience: { '@type': 'Audience', name: meta.audience } } : {}),
53
+ hasPart: {
54
+ '@type': 'ItemList',
55
+ numberOfItems: claims.length,
56
+ itemListElement: claims.map((claim, i) => ({
57
+ '@type': 'ListItem',
58
+ position: i + 1,
59
+ item: claimToJsonLd(claim),
60
+ })),
61
+ },
62
+ ...((certificate && certificate.sha256) ? {
63
+ identifier: {
64
+ '@type': 'PropertyValue',
65
+ name: 'certificate-sha256',
66
+ value: certificate.sha256,
67
+ },
68
+ } : {}),
69
+ };
70
+ }
71
+
72
+ module.exports = { CONTEXT, claimToJsonLd, buildReport };
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { execFile } = require('node:child_process');
6
+
7
+ /**
8
+ * Copy formatted output to system clipboard.
9
+ * Uses pbcopy (macOS), xclip (Linux), or clip (Windows).
10
+ */
11
+
12
+ function getClipboardCommand() {
13
+ switch (process.platform) {
14
+ case 'darwin':
15
+ return { cmd: 'pbcopy', args: [] };
16
+ case 'linux':
17
+ return { cmd: 'xclip', args: ['-selection', 'clipboard'] };
18
+ case 'win32':
19
+ return { cmd: 'clip', args: [] };
20
+ default:
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function copyToClipboard(text) {
26
+ return new Promise((resolve, reject) => {
27
+ const clip = getClipboardCommand();
28
+ if (!clip) {
29
+ reject(new Error(`Clipboard not supported on ${process.platform}`));
30
+ return;
31
+ }
32
+
33
+ const proc = execFile(clip.cmd, clip.args, (err) => {
34
+ if (err) reject(new Error(`Clipboard copy failed: ${err.message}`));
35
+ else resolve();
36
+ });
37
+
38
+ proc.stdin.write(text);
39
+ proc.stdin.end();
40
+ });
41
+ }
42
+
43
+ async function publishClipboard(inputPath) {
44
+ const stat = fs.statSync(inputPath);
45
+
46
+ let content;
47
+ if (stat.isDirectory()) {
48
+ // If directory, list the files
49
+ const files = fs.readdirSync(inputPath).filter((f) => !f.startsWith('.'));
50
+ content = files.map((f) => {
51
+ const full = path.join(inputPath, f);
52
+ return fs.readFileSync(full, 'utf-8');
53
+ }).join('\n\n---\n\n');
54
+ } else {
55
+ content = fs.readFileSync(inputPath, 'utf-8');
56
+ }
57
+
58
+ await copyToClipboard(content);
59
+
60
+ const size = Buffer.byteLength(content, 'utf-8');
61
+ return {
62
+ message: `Copied to clipboard (${size} bytes)`,
63
+ };
64
+ }
65
+
66
+ module.exports = {
67
+ name: 'clipboard',
68
+ description: 'Copy formatted output to system clipboard',
69
+ publish: publishClipboard,
70
+ };
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
7
+
8
+ /**
9
+ * Generate a static site from sprint output directory.
10
+ * Creates an index.html that links to all artifacts with
11
+ * a dark-themed navigation page.
12
+ */
13
+
14
+ const TEMPLATE = (title, items) => `<!DOCTYPE html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>${title}</title>
20
+ <style>
21
+ * { margin: 0; padding: 0; box-sizing: border-box; }
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
24
+ background: #111;
25
+ color: #e5e5e5;
26
+ min-height: 100vh;
27
+ padding: 2rem;
28
+ }
29
+ h1 {
30
+ color: #d97706;
31
+ font-size: 1.8rem;
32
+ margin-bottom: 0.5rem;
33
+ }
34
+ .subtitle {
35
+ color: #888;
36
+ margin-bottom: 2rem;
37
+ font-size: 0.9rem;
38
+ }
39
+ .artifacts {
40
+ display: grid;
41
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
42
+ gap: 1rem;
43
+ }
44
+ .card {
45
+ background: #1a1a1a;
46
+ border: 1px solid #333;
47
+ border-radius: 8px;
48
+ padding: 1.2rem;
49
+ transition: border-color 0.2s;
50
+ }
51
+ .card:hover { border-color: #d97706; }
52
+ .card a {
53
+ color: #d97706;
54
+ text-decoration: none;
55
+ font-weight: 600;
56
+ font-size: 1.05rem;
57
+ }
58
+ .card a:hover { text-decoration: underline; }
59
+ .card .meta {
60
+ color: #888;
61
+ font-size: 0.8rem;
62
+ margin-top: 0.4rem;
63
+ }
64
+ footer {
65
+ margin-top: 3rem;
66
+ color: #555;
67
+ font-size: 0.75rem;
68
+ text-align: center;
69
+ }
70
+ </style>
71
+ </head>
72
+ <body>
73
+ <h1>${title}</h1>
74
+ <p class="subtitle">Generated by mill</p>
75
+ <div class="artifacts">
76
+ ${items}
77
+ </div>
78
+ <footer>Built with @grainulation/mill</footer>
79
+ </body>
80
+ </html>
81
+ `;
82
+
83
+ function formatBytes(bytes) {
84
+ if (bytes < 1024) return `${bytes} B`;
85
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
86
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
87
+ }
88
+
89
+ function scanDir(dir, base) {
90
+ const entries = [];
91
+ for (const name of fs.readdirSync(dir)) {
92
+ const full = path.join(dir, name);
93
+ const stat = fs.statSync(full);
94
+ const real = fs.realpathSync(full);
95
+ if (!real.startsWith(fs.realpathSync(base))) continue;
96
+ if (stat.isFile() && !name.startsWith('.')) {
97
+ const rel = path.relative(base, full);
98
+ entries.push({
99
+ name,
100
+ path: rel,
101
+ size: stat.size,
102
+ ext: path.extname(name).slice(1).toLowerCase(),
103
+ modified: stat.mtime,
104
+ });
105
+ } else if (stat.isDirectory() && !name.startsWith('.') && name !== '_site') {
106
+ entries.push(...scanDir(full, base));
107
+ }
108
+ }
109
+ return entries;
110
+ }
111
+
112
+ async function publishStatic(inputDir, outputDir) {
113
+ const outDir = outputDir || path.join(inputDir, '_site');
114
+ if (!fs.existsSync(outDir)) {
115
+ fs.mkdirSync(outDir, { recursive: true });
116
+ }
117
+
118
+ const entries = scanDir(inputDir, inputDir);
119
+
120
+ // Copy all files to _site
121
+ for (const entry of entries) {
122
+ const src = path.join(inputDir, entry.path);
123
+ const dest = path.join(outDir, entry.path);
124
+ const destDir = path.dirname(dest);
125
+ if (!fs.existsSync(destDir)) {
126
+ fs.mkdirSync(destDir, { recursive: true });
127
+ }
128
+ fs.copyFileSync(src, dest);
129
+ }
130
+
131
+ // Build index
132
+ const sprintName = path.basename(path.resolve(inputDir));
133
+ const cards = entries.map((e) => `
134
+ <div class="card">
135
+ <a href="${esc(e.path)}">${esc(e.name)}</a>
136
+ <div class="meta">${e.ext.toUpperCase()} &middot; ${formatBytes(e.size)}</div>
137
+ </div>`).join('\n');
138
+
139
+ const html = TEMPLATE(`Sprint: ${sprintName}`, cards);
140
+ fs.writeFileSync(path.join(outDir, 'index.html'), html, 'utf-8');
141
+
142
+ return {
143
+ outputPath: outDir,
144
+ message: `Static site written to ${outDir} (${entries.length} artifacts)`,
145
+ };
146
+ }
147
+
148
+ module.exports = {
149
+ name: 'static',
150
+ description: 'Generate a static site from sprint outputs',
151
+ publish: publishStatic,
152
+ };