@drunkcoding/agents-and-skills 0.0.1 → 0.0.3
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/.claude-plugin/marketplace.json +17 -2
- package/README.md +32 -1
- package/package.json +1 -1
- package/plugins/html-effectiveness/.claude-plugin/plugin.json +13 -0
- package/plugins/html-effectiveness/README.md +23 -0
- package/plugins/html-effectiveness/assets/base.css +31 -0
- package/plugins/html-effectiveness/assets/base.js +11 -0
- package/plugins/html-effectiveness/assets/charts.js +1 -0
- package/plugins/html-effectiveness/assets/components.css +10 -0
- package/plugins/html-effectiveness/package.json +6 -0
- package/plugins/html-effectiveness/scripts/mustache.js +90 -0
- package/plugins/html-effectiveness/scripts/render.js +176 -0
- package/plugins/html-effectiveness/templates/manifest.json +7 -0
- package/plugins/html-effectiveness/tests/fixtures/_canned.data.json +7 -0
- package/plugins/html-effectiveness/tests/fixtures/_canned.html.tmpl +7 -0
- package/plugins/html-effectiveness/tests/fixtures/_canned.manifest.json +18 -0
- package/plugins/html-effectiveness/tests/mustache.test.js +47 -0
- package/plugins/html-effectiveness/tests/render.test.js +118 -0
- package/plugins/tech-graph/.claude-plugin/plugin.json +1 -1
- package/plugins/tech-graph/README.md +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "drunkcoding",
|
|
3
3
|
"owner": {
|
|
4
4
|
"name": "steven",
|
|
5
5
|
"email": "steven.hoang@transwap.com"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "tech-graph",
|
|
13
13
|
"source": "./plugins/tech-graph",
|
|
14
14
|
"description": "6-step wizard for technical diagrams (SVG/PNG) via fireworks-tech-graph",
|
|
15
|
-
"version": "0.0.
|
|
15
|
+
"version": "0.0.3",
|
|
16
16
|
"category": "diagram",
|
|
17
17
|
"keywords": [
|
|
18
18
|
"diagram",
|
|
@@ -21,6 +21,21 @@
|
|
|
21
21
|
"uml",
|
|
22
22
|
"flowchart"
|
|
23
23
|
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "html-effectiveness",
|
|
27
|
+
"source": "./plugins/html-effectiveness",
|
|
28
|
+
"description": "Generate self-contained interactive HTML reports from 20 upstream templates via a conversational agent.",
|
|
29
|
+
"version": "0.0.3",
|
|
30
|
+
"category": "reports",
|
|
31
|
+
"keywords": [
|
|
32
|
+
"html",
|
|
33
|
+
"report",
|
|
34
|
+
"status",
|
|
35
|
+
"incident",
|
|
36
|
+
"slide-deck",
|
|
37
|
+
"decision-matrix"
|
|
38
|
+
]
|
|
24
39
|
}
|
|
25
40
|
]
|
|
26
41
|
}
|
package/README.md
CHANGED
|
@@ -9,18 +9,49 @@ Published on npm as [`@drunkcoding/agents-and-skills`](https://www.npmjs.com/pac
|
|
|
9
9
|
| Plugin | Description |
|
|
10
10
|
|--------|-------------|
|
|
11
11
|
| [`tech-graph`](plugins/tech-graph) | 6-step wizard for technical diagrams (SVG + PNG). |
|
|
12
|
+
| [`html-effectiveness`](plugins/html-effectiveness) | Conversational agent that generates self-contained interactive HTML reports from 20 templates. |
|
|
12
13
|
|
|
13
14
|
## Install
|
|
14
15
|
|
|
15
16
|
### Claude Code marketplace
|
|
16
17
|
|
|
18
|
+
Add the marketplace once:
|
|
19
|
+
|
|
17
20
|
```text
|
|
18
21
|
/plugin marketplace add drunkcoding/agents-and-skills
|
|
19
|
-
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then install any plugin individually:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
/plugin install tech-graph@drunkcoding
|
|
28
|
+
/plugin install html-effectiveness@drunkcoding
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Reload after install:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
/reload-plugins
|
|
20
35
|
```
|
|
21
36
|
|
|
22
37
|
Only the plugin you install loads; others stay dormant.
|
|
23
38
|
|
|
39
|
+
#### Project-scoped install
|
|
40
|
+
|
|
41
|
+
Install into a project's `.claude/` instead of your user profile:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
/plugin install tech-graph@drunkcoding --scope project
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
#### Manage installed plugins
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
/plugin list # see installed plugins
|
|
51
|
+
/plugin uninstall <name> # remove
|
|
52
|
+
/plugin marketplace update # pull latest manifest
|
|
53
|
+
```
|
|
54
|
+
|
|
24
55
|
### npm / npx
|
|
25
56
|
|
|
26
57
|
```bash
|
package/package.json
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "html-effectiveness",
|
|
3
|
+
"displayName": "HTML Effectiveness Reports",
|
|
4
|
+
"version": "0.0.3",
|
|
5
|
+
"description": "Generate self-contained interactive HTML reports from 20 upstream templates via a conversational agent.",
|
|
6
|
+
"author": "Steven Hoang",
|
|
7
|
+
"skills": [
|
|
8
|
+
"skills/html-effectiveness"
|
|
9
|
+
],
|
|
10
|
+
"agents": [
|
|
11
|
+
"agents/report-builder.md"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# html-effectiveness
|
|
2
|
+
|
|
3
|
+
Generate self-contained interactive HTML reports (status, incident, PR writeup, decision matrix, slide deck, code review, research explainer, etc.) from 20 upstream templates.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
Invoke the `report-builder` agent. It will pick a template, gather slot data conversationally, and render to `docs/reports/YYYY-MM-DD-<slug>.html`.
|
|
8
|
+
|
|
9
|
+
Override output dir in repo `CLAUDE.md`:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
html-effectiveness.reportDir: my/custom/reports
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Sync upstream
|
|
16
|
+
|
|
17
|
+
Re-fetch templates from `ThariqS/html-effectiveness`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
node plugins/html-effectiveness/scripts/sync-upstream.js
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
See `docs/template-gallery.md` for the full pattern catalog.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Theme variables, typography, print baseline. Populated from upstream during Task 7. */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #ffffff;
|
|
4
|
+
--fg: #111111;
|
|
5
|
+
--muted: #666666;
|
|
6
|
+
--accent: #2563eb;
|
|
7
|
+
--border: #e5e7eb;
|
|
8
|
+
--radius: 6px;
|
|
9
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
10
|
+
--mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
|
11
|
+
--transition: 150ms ease;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) {
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #0b0b0c;
|
|
16
|
+
--fg: #f5f5f7;
|
|
17
|
+
--muted: #9aa0a6;
|
|
18
|
+
--accent: #60a5fa;
|
|
19
|
+
--border: #2a2a2c;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: var(--font); }
|
|
24
|
+
a { color: var(--accent); text-decoration: none; }
|
|
25
|
+
a:hover { text-decoration: underline; }
|
|
26
|
+
@media print {
|
|
27
|
+
details { display: block; }
|
|
28
|
+
details > summary { display: none; }
|
|
29
|
+
[hidden] { display: revert !important; }
|
|
30
|
+
body { background: white; color: black; }
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Shared interactions: theme toggle, search, nav, copy-on-click. Populated from upstream during Task 7.
|
|
2
|
+
(function () {
|
|
3
|
+
document.addEventListener('click', (e) => {
|
|
4
|
+
const t = e.target.closest('[data-copy]');
|
|
5
|
+
if (!t) return;
|
|
6
|
+
const text = t.getAttribute('data-copy');
|
|
7
|
+
navigator.clipboard?.writeText(text);
|
|
8
|
+
t.setAttribute('data-copied', '1');
|
|
9
|
+
setTimeout(() => t.removeAttribute('data-copied'), 1000);
|
|
10
|
+
});
|
|
11
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Inline SVG chart helpers. Opt-in via manifest.extra_js. Populated from upstream during Task 7.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Shared components: cards, timeline, kanban, matrix, swatches, slides. Populated from upstream during Task 7. */
|
|
2
|
+
.report { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; }
|
|
3
|
+
.report header h1 { font-size: 1.75rem; margin: 0 0 .25rem; }
|
|
4
|
+
.report header .subtitle { color: var(--muted); margin: 0 0 1.5rem; }
|
|
5
|
+
.card { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; transition: border-color var(--transition); }
|
|
6
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin: 0 0 2rem; }
|
|
7
|
+
.stat { display: flex; flex-direction: column; gap: .25rem; }
|
|
8
|
+
.stat .value { font-size: 1.5rem; font-weight: 600; }
|
|
9
|
+
.stat .label { color: var(--muted); font-size: .875rem; }
|
|
10
|
+
.stat .delta { font-size: .75rem; }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Hand-rolled mustache subset: {{var}}, {{{raw}}}, {{#section}}…{{/section}}, {{^inverted}}…{{/inverted}}
|
|
2
|
+
// Zero runtime dependencies.
|
|
3
|
+
|
|
4
|
+
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
5
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
|
|
6
|
+
|
|
7
|
+
function tokenize(template) {
|
|
8
|
+
const tokens = [];
|
|
9
|
+
const re = /\{\{(\{?)([\^#\/]?)\s*([\w.-]+)\s*\}?\}\}/g;
|
|
10
|
+
let lastIndex = 0;
|
|
11
|
+
let m;
|
|
12
|
+
while ((m = re.exec(template)) !== null) {
|
|
13
|
+
if (m.index > lastIndex) tokens.push({ type: 'text', value: template.slice(lastIndex, m.index) });
|
|
14
|
+
const [, triple, sigil, name] = m;
|
|
15
|
+
if (sigil === '#') tokens.push({ type: 'section', name });
|
|
16
|
+
else if (sigil === '^') tokens.push({ type: 'inverted', name });
|
|
17
|
+
else if (sigil === '/') tokens.push({ type: 'close', name });
|
|
18
|
+
else if (triple === '{') tokens.push({ type: 'raw', name });
|
|
19
|
+
else tokens.push({ type: 'var', name });
|
|
20
|
+
lastIndex = re.lastIndex;
|
|
21
|
+
}
|
|
22
|
+
if (lastIndex < template.length) tokens.push({ type: 'text', value: template.slice(lastIndex) });
|
|
23
|
+
return tokens;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parse(tokens, end = null) {
|
|
27
|
+
const nodes = [];
|
|
28
|
+
while (tokens.length) {
|
|
29
|
+
const t = tokens.shift();
|
|
30
|
+
if (t.type === 'close') {
|
|
31
|
+
if (t.name !== end) throw new Error(`Mismatched close tag: expected ${end}, got ${t.name}`);
|
|
32
|
+
return nodes;
|
|
33
|
+
}
|
|
34
|
+
if (t.type === 'section' || t.type === 'inverted') {
|
|
35
|
+
const children = parse(tokens, t.name);
|
|
36
|
+
nodes.push({ ...t, children });
|
|
37
|
+
} else {
|
|
38
|
+
nodes.push(t);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (end !== null) throw new Error(`Unclosed section: ${end}`);
|
|
42
|
+
return nodes;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lookup(ctxStack, name) {
|
|
46
|
+
if (name === '.') return ctxStack[ctxStack.length - 1];
|
|
47
|
+
const parts = name.split('.');
|
|
48
|
+
for (let i = ctxStack.length - 1; i >= 0; i--) {
|
|
49
|
+
let cur = ctxStack[i];
|
|
50
|
+
let ok = true;
|
|
51
|
+
for (const p of parts) {
|
|
52
|
+
if (cur == null || typeof cur !== 'object' || !Object.prototype.hasOwnProperty.call(cur, p)) { ok = false; break; }
|
|
53
|
+
cur = cur[p];
|
|
54
|
+
}
|
|
55
|
+
if (ok) return cur;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emit(nodes, ctxStack) {
|
|
61
|
+
let out = '';
|
|
62
|
+
for (const n of nodes) {
|
|
63
|
+
if (n.type === 'text') out += n.value;
|
|
64
|
+
else if (n.type === 'var') {
|
|
65
|
+
const v = lookup(ctxStack, n.name);
|
|
66
|
+
out += v == null ? '' : escapeHtml(v);
|
|
67
|
+
} else if (n.type === 'raw') {
|
|
68
|
+
const v = lookup(ctxStack, n.name);
|
|
69
|
+
out += v == null ? '' : String(v);
|
|
70
|
+
} else if (n.type === 'section') {
|
|
71
|
+
const v = lookup(ctxStack, n.name);
|
|
72
|
+
if (Array.isArray(v)) {
|
|
73
|
+
for (const item of v) out += emit(n.children, [...ctxStack, item]);
|
|
74
|
+
} else if (v && (typeof v !== 'object' || Object.keys(v).length > 0)) {
|
|
75
|
+
out += emit(n.children, [...ctxStack, v]);
|
|
76
|
+
}
|
|
77
|
+
} else if (n.type === 'inverted') {
|
|
78
|
+
const v = lookup(ctxStack, n.name);
|
|
79
|
+
const empty = v == null || v === false || (Array.isArray(v) && v.length === 0);
|
|
80
|
+
if (empty) out += emit(n.children, ctxStack);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function render(template, data) {
|
|
87
|
+
const tokens = tokenize(template);
|
|
88
|
+
const tree = parse(tokens);
|
|
89
|
+
return emit(tree, [data]);
|
|
90
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI + library: render a template + data into a self-contained HTML file.
|
|
3
|
+
// Exit codes: 0 ok, 1 generic, 2 template-not-found, 3 slot schema mismatch, 4 fs write error.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { render as mustache } from './mustache.js';
|
|
10
|
+
|
|
11
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PLUGIN_ROOT = dirname(SCRIPT_DIR);
|
|
13
|
+
|
|
14
|
+
const ASSET_BUNDLES = {
|
|
15
|
+
base: { css: 'assets/base.css', js: 'assets/base.js' },
|
|
16
|
+
components: { css: 'assets/components.css' },
|
|
17
|
+
charts: { js: 'assets/charts.js' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function validateSlot(templateId, slotPath, schema, value) {
|
|
21
|
+
if (schema.required && (value === undefined || value === null)) {
|
|
22
|
+
const err = new Error(`${templateId}:${slotPath} required but missing`);
|
|
23
|
+
err.code = 3;
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
if (value === undefined || value === null) return;
|
|
27
|
+
const type = schema.type;
|
|
28
|
+
if (type === 'string' || type === 'html') {
|
|
29
|
+
if (typeof value !== 'string') {
|
|
30
|
+
const err = new Error(`${templateId}:${slotPath} expected string got ${typeof value}`);
|
|
31
|
+
err.code = 3;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
} else if (type === 'number') {
|
|
35
|
+
if (typeof value !== 'number') {
|
|
36
|
+
const err = new Error(`${templateId}:${slotPath} expected number got ${typeof value}`);
|
|
37
|
+
err.code = 3;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
} else if (type === 'boolean') {
|
|
41
|
+
if (typeof value !== 'boolean') {
|
|
42
|
+
const err = new Error(`${templateId}:${slotPath} expected boolean got ${typeof value}`);
|
|
43
|
+
err.code = 3;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
} else if (type === 'array') {
|
|
47
|
+
if (!Array.isArray(value)) {
|
|
48
|
+
const err = new Error(`${templateId}:${slotPath} expected array got ${typeof value}`);
|
|
49
|
+
err.code = 3;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
if (schema.of) {
|
|
53
|
+
value.forEach((item, i) => {
|
|
54
|
+
for (const [k, subType] of Object.entries(schema.of)) {
|
|
55
|
+
const subSchema = typeof subType === 'string' ? { type: subType } : subType;
|
|
56
|
+
validateSlot(templateId, `${slotPath}[${i}].${k}`, subSchema, item?.[k]);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
} else if (type === 'object') {
|
|
61
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
62
|
+
const err = new Error(`${templateId}:${slotPath} expected object got ${typeof value}`);
|
|
63
|
+
err.code = 3;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validate(templateId, slots, data) {
|
|
70
|
+
for (const [name, schemaRaw] of Object.entries(slots)) {
|
|
71
|
+
const schema = typeof schemaRaw === 'string' ? { type: schemaRaw } : schemaRaw;
|
|
72
|
+
validateSlot(templateId, name, schema, data[name]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function loadAssets(pluginRoot, bundles, extraJs) {
|
|
77
|
+
const css = [];
|
|
78
|
+
const js = [];
|
|
79
|
+
for (const name of bundles || []) {
|
|
80
|
+
const b = ASSET_BUNDLES[name];
|
|
81
|
+
if (!b) continue;
|
|
82
|
+
if (b.css) css.push(readFileSync(join(pluginRoot, b.css), 'utf8'));
|
|
83
|
+
if (b.js) js.push(readFileSync(join(pluginRoot, b.js), 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
if (extraJs) js.push(readFileSync(join(pluginRoot, extraJs), 'utf8'));
|
|
86
|
+
return { css: css.join('\n'), js: js.join('\n') };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeForTitle(s) {
|
|
90
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
91
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
92
|
+
}[c]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renderReport({ templateId, data, outPath, manifestPath, pluginRoot }) {
|
|
96
|
+
const root = pluginRoot || PLUGIN_ROOT;
|
|
97
|
+
const mPath = manifestPath || join(root, 'templates/manifest.json');
|
|
98
|
+
const manifest = JSON.parse(readFileSync(mPath, 'utf8'));
|
|
99
|
+
const entry = manifest[templateId];
|
|
100
|
+
if (!entry) {
|
|
101
|
+
const ids = Object.keys(manifest).filter((k) => k !== '_meta');
|
|
102
|
+
const err = new Error(`template not found: ${templateId}. valid: ${ids.join(', ')}`);
|
|
103
|
+
err.code = 2;
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
validate(templateId, entry.slots, data);
|
|
108
|
+
|
|
109
|
+
const tmplRelPath = entry.tmpl || `templates/${templateId}.html.tmpl`;
|
|
110
|
+
const tmpl = readFileSync(join(root, tmplRelPath), 'utf8');
|
|
111
|
+
const body = mustache(tmpl, data);
|
|
112
|
+
|
|
113
|
+
const { css, js } = loadAssets(root, entry.asset_bundles, entry.extra_js);
|
|
114
|
+
const title = escapeForTitle(data.title || entry.title || 'Report');
|
|
115
|
+
|
|
116
|
+
const html = `<!doctype html>
|
|
117
|
+
<html lang="en">
|
|
118
|
+
<head>
|
|
119
|
+
<meta charset="utf-8">
|
|
120
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
121
|
+
<title>${title}</title>
|
|
122
|
+
<style>${css}</style>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
${body}
|
|
126
|
+
<script>${js}</script>
|
|
127
|
+
</body>
|
|
128
|
+
</html>
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
133
|
+
writeFileSync(outPath, html, 'utf8');
|
|
134
|
+
const sidecar = {
|
|
135
|
+
template: templateId,
|
|
136
|
+
rendered_at: new Date().toISOString(),
|
|
137
|
+
render_version: '0.1.0',
|
|
138
|
+
data,
|
|
139
|
+
};
|
|
140
|
+
writeFileSync(outPath + '.data.json', JSON.stringify(sidecar, null, 2), 'utf8');
|
|
141
|
+
} catch (e) {
|
|
142
|
+
const err = new Error(`failed to write ${outPath}: ${e.message}`);
|
|
143
|
+
err.code = 4;
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return outPath;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cli() {
|
|
151
|
+
const { values } = parseArgs({
|
|
152
|
+
options: {
|
|
153
|
+
template: { type: 'string' },
|
|
154
|
+
data: { type: 'string' },
|
|
155
|
+
out: { type: 'string' },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
if (!values.template || !values.data || !values.out) {
|
|
159
|
+
process.stderr.write('usage: render.js --template=<id> --data=<json-path> --out=<html-path>\n');
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const data = JSON.parse(readFileSync(resolve(values.data), 'utf8'));
|
|
164
|
+
const finalPath = renderReport({
|
|
165
|
+
templateId: values.template,
|
|
166
|
+
data,
|
|
167
|
+
outPath: resolve(values.out),
|
|
168
|
+
});
|
|
169
|
+
process.stdout.write(finalPath + '\n');
|
|
170
|
+
} catch (e) {
|
|
171
|
+
process.stderr.write((e.stack || e.message) + '\n');
|
|
172
|
+
process.exit(e.code ?? 1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (import.meta.url === `file://${process.argv[1]}`) cli();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": { "upstream_repo": "test", "upstream_sha": "test", "extracted_at": "test" },
|
|
3
|
+
"_canned": {
|
|
4
|
+
"title": "Canned",
|
|
5
|
+
"use_cases": ["test"],
|
|
6
|
+
"pattern": "test",
|
|
7
|
+
"tmpl": "fixtures/_canned.html.tmpl",
|
|
8
|
+
"slots": {
|
|
9
|
+
"title": { "type": "string", "required": true },
|
|
10
|
+
"items": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"of": { "name": "string", "html": "html" }
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"asset_bundles": [],
|
|
16
|
+
"extra_js": null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { render } from '../scripts/mustache.js';
|
|
4
|
+
|
|
5
|
+
test('renders bare variable, escaped by default', () => {
|
|
6
|
+
assert.equal(render('Hello {{name}}', { name: 'World' }), 'Hello World');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('escapes HTML special chars in {{var}}', () => {
|
|
10
|
+
assert.equal(
|
|
11
|
+
render('{{x}}', { x: '<script>alert(1)</script>' }),
|
|
12
|
+
'<script>alert(1)</script>'
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('triple-stash {{{raw}}} bypasses escaping', () => {
|
|
17
|
+
assert.equal(render('{{{html}}}', { html: '<b>x</b>' }), '<b>x</b>');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('section iterates array with sub-context', () => {
|
|
21
|
+
const tmpl = '{{#items}}[{{name}}]{{/items}}';
|
|
22
|
+
assert.equal(render(tmpl, { items: [{ name: 'a' }, { name: 'b' }] }), '[a][b]');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('inverted section renders only when falsy/empty', () => {
|
|
26
|
+
assert.equal(render('{{^items}}empty{{/items}}', { items: [] }), 'empty');
|
|
27
|
+
assert.equal(render('{{^items}}empty{{/items}}', { items: [{}] }), '');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('missing key renders as empty string, not "undefined"', () => {
|
|
31
|
+
assert.equal(render('x={{missing}}', {}), 'x=');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('nested sections respect inherited context', () => {
|
|
35
|
+
const tmpl = '{{#a}}{{#b}}{{x}}{{/b}}{{/a}}';
|
|
36
|
+
assert.equal(render(tmpl, { a: { b: [{ x: 'ok' }] } }), 'ok');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('falsy section (false, null, undefined, empty array) renders nothing', () => {
|
|
40
|
+
for (const val of [false, null, undefined, []]) {
|
|
41
|
+
assert.equal(render('{{#x}}body{{/x}}', { x: val }), '');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('preserves literal text around tags', () => {
|
|
46
|
+
assert.equal(render('a {{x}} b', { x: '1' }), 'a 1 b');
|
|
47
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { renderReport } from '../scripts/render.js';
|
|
8
|
+
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const fixtures = join(here, 'fixtures');
|
|
11
|
+
|
|
12
|
+
function setup() {
|
|
13
|
+
const out = mkdtempSync(join(tmpdir(), 'htmleff-'));
|
|
14
|
+
return {
|
|
15
|
+
out,
|
|
16
|
+
cleanup: () => rmSync(out, { recursive: true, force: true }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('renders canned template into self-contained HTML', () => {
|
|
21
|
+
const { out, cleanup } = setup();
|
|
22
|
+
try {
|
|
23
|
+
const outPath = join(out, 'demo.html');
|
|
24
|
+
renderReport({
|
|
25
|
+
templateId: '_canned',
|
|
26
|
+
data: JSON.parse(readFileSync(join(fixtures, '_canned.data.json'), 'utf8')),
|
|
27
|
+
outPath,
|
|
28
|
+
manifestPath: join(fixtures, '_canned.manifest.json'),
|
|
29
|
+
pluginRoot: dirname(fixtures),
|
|
30
|
+
});
|
|
31
|
+
const html = readFileSync(outPath, 'utf8');
|
|
32
|
+
assert.ok(html.startsWith('<!doctype html>'), 'starts with doctype');
|
|
33
|
+
assert.ok(html.includes('<title>Demo <Report></title>'), 'title escaped in head');
|
|
34
|
+
assert.ok(html.includes('Demo <Report></h1>'), 'title escaped in body');
|
|
35
|
+
assert.ok(html.includes('<b>bold</b>'), 'html slot rendered raw');
|
|
36
|
+
assert.ok(!html.match(/\{\{[^}]+\}\}/), 'no leftover mustache markers');
|
|
37
|
+
assert.ok(!html.includes('<link'), 'no external link');
|
|
38
|
+
assert.ok(!html.match(/<script\s+src=/i), 'no external script src');
|
|
39
|
+
} finally {
|
|
40
|
+
cleanup();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('writes sidecar .data.json next to output', () => {
|
|
45
|
+
const { out, cleanup } = setup();
|
|
46
|
+
try {
|
|
47
|
+
const outPath = join(out, 'demo.html');
|
|
48
|
+
const data = JSON.parse(readFileSync(join(fixtures, '_canned.data.json'), 'utf8'));
|
|
49
|
+
renderReport({
|
|
50
|
+
templateId: '_canned',
|
|
51
|
+
data,
|
|
52
|
+
outPath,
|
|
53
|
+
manifestPath: join(fixtures, '_canned.manifest.json'),
|
|
54
|
+
pluginRoot: dirname(fixtures),
|
|
55
|
+
});
|
|
56
|
+
const sidecarPath = outPath + '.data.json';
|
|
57
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'));
|
|
58
|
+
assert.equal(sidecar.template, '_canned');
|
|
59
|
+
assert.deepEqual(sidecar.data, data);
|
|
60
|
+
assert.ok(sidecar.rendered_at);
|
|
61
|
+
} finally {
|
|
62
|
+
cleanup();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('throws on missing required slot with template:slot context', () => {
|
|
67
|
+
const { out, cleanup } = setup();
|
|
68
|
+
try {
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => renderReport({
|
|
71
|
+
templateId: '_canned',
|
|
72
|
+
data: { items: [] },
|
|
73
|
+
outPath: join(out, 'x.html'),
|
|
74
|
+
manifestPath: join(fixtures, '_canned.manifest.json'),
|
|
75
|
+
pluginRoot: dirname(fixtures),
|
|
76
|
+
}),
|
|
77
|
+
/_canned:title.*required/,
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
cleanup();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('throws on unknown template id with valid-ids list', () => {
|
|
85
|
+
const { out, cleanup } = setup();
|
|
86
|
+
try {
|
|
87
|
+
assert.throws(
|
|
88
|
+
() => renderReport({
|
|
89
|
+
templateId: 'does-not-exist',
|
|
90
|
+
data: {},
|
|
91
|
+
outPath: join(out, 'x.html'),
|
|
92
|
+
manifestPath: join(fixtures, '_canned.manifest.json'),
|
|
93
|
+
pluginRoot: dirname(fixtures),
|
|
94
|
+
}),
|
|
95
|
+
/template not found.*_canned/i,
|
|
96
|
+
);
|
|
97
|
+
} finally {
|
|
98
|
+
cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('inverted section renders fallback when array empty', () => {
|
|
103
|
+
const { out, cleanup } = setup();
|
|
104
|
+
try {
|
|
105
|
+
const outPath = join(out, 'empty.html');
|
|
106
|
+
renderReport({
|
|
107
|
+
templateId: '_canned',
|
|
108
|
+
data: { title: 'Empty', items: [] },
|
|
109
|
+
outPath,
|
|
110
|
+
manifestPath: join(fixtures, '_canned.manifest.json'),
|
|
111
|
+
pluginRoot: dirname(fixtures),
|
|
112
|
+
});
|
|
113
|
+
const html = readFileSync(outPath, 'utf8');
|
|
114
|
+
assert.ok(html.includes('No items.'));
|
|
115
|
+
} finally {
|
|
116
|
+
cleanup();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
@@ -7,8 +7,8 @@ Step-by-step wizard for generating technical diagrams (architecture, flowchart,
|
|
|
7
7
|
From inside Claude Code:
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
/plugin marketplace add <user>/
|
|
11
|
-
/plugin install tech-graph@
|
|
10
|
+
/plugin marketplace add <user>/agents-and-skills
|
|
11
|
+
/plugin install tech-graph@drunkcoding
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Then run `/tech-graph` (or `/tech-graph "auth flow"` to seed a topic).
|