@thanbir-tamim/enterprise-project-intelligence-agent 0.1.1 → 0.2.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 +14 -0
- package/README.md +302 -49
- package/assets/figures/feasibility-scoring-engine.png +0 -0
- package/assets/figures/hero-architecture.png +0 -0
- package/assets/figures/skills-interaction-map.png +0 -0
- package/docs/SUPPORTED_FORMATS.md +4 -4
- package/examples/synthetic-inventory-system/ai-opportunity-matrix/AI_OPPORTUNITY_MATRIX.md +4 -4
- package/examples/synthetic-inventory-system/generated-docs/INVENTORY_SUMMARY.md +17 -0
- package/examples/synthetic-inventory-system/generated-docs/evidence-index.json +78 -0
- package/examples/synthetic-inventory-system/generated-docs/inventory.json +26 -0
- package/examples/synthetic-inventory-system/presentation/DEMO_DECK.md +69 -55
- package/examples/synthetic-inventory-system/presentation/demo-deck.json +194 -16
- package/package.json +2 -1
- package/scripts/cli.mjs +20 -14
- package/scripts/lib/skills.mjs +1 -1
- package/scripts/release-audit.mjs +73 -70
- package/skills/corporate-presentation-generator/SKILL.md +49 -23
- package/skills/corporate-presentation-generator/scripts/outline-to-deck-json.mjs +77 -0
- package/skills/documentation-quality-auditor/SKILL.md +28 -21
- package/skills/documentation-quality-auditor/references/audit-checks.md +15 -8
- package/skills/documentation-quality-auditor/scripts/audit-docs.mjs +192 -32
- package/skills/engineering-knowledge-transfer/SKILL.md +1 -5
- package/skills/enterprise-ai-integration-planner/SKILL.md +13 -15
- package/skills/enterprise-ai-integration-planner/references/feasibility-scoring.md +26 -5
- package/skills/enterprise-ai-integration-planner/scripts/score-opportunity.mjs +32 -15
- package/skills/presentation-compliance-validator/SKILL.md +15 -15
- package/skills/presentation-compliance-validator/references/rule-schema.md +67 -10
- package/skills/presentation-compliance-validator/scripts/validate-deck.mjs +48 -11
- package/skills/project-documentation-discovery/SKILL.md +25 -8
- package/skills/project-documentation-discovery/references/format-matrix.md +4 -4
- package/skills/project-documentation-discovery/scripts/inventory.mjs +174 -100
- package/skills/project-documentation-discovery/scripts/secrets.mjs +99 -0
- package/skills/project-knowledge-assistant/SKILL.md +1 -7
- package/skills/software-change-impact-analyzer/SKILL.md +15 -11
- package/skills/software-reverse-engineering/SKILL.md +14 -10
- package/skills/software-reverse-engineering/references/system-map-schema.md +27 -0
- package/skills/technical-document-generator/SKILL.md +6 -8
- package/skills/technical-document-generator/scripts/render-markdown-to-html.mjs +76 -66
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared secret-likeness detection (no dependencies).
|
|
3
|
+
* Used by inventory.mjs (redaction before indexing) and by the repo-level release audit.
|
|
4
|
+
*
|
|
5
|
+
* Design goals (from the evaluation findings F-08/F-09):
|
|
6
|
+
* - catch real-looking secrets: private-key blocks, keyed assignments (API_KEY=..., token: ...),
|
|
7
|
+
* AWS access key IDs, bearer tokens, credentialed connection strings, high-entropy tokens;
|
|
8
|
+
* - do NOT flag ordinary identifiers (SNAKE_CASE / kebab-case names), slash-separated prose,
|
|
9
|
+
* or file paths — those caused false positives and destroyed evidence headings.
|
|
10
|
+
* Matching is heuristic: a safeguard, not proof that no secret exists.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function shannonEntropy(s) {
|
|
14
|
+
const freq = new Map();
|
|
15
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
16
|
+
let h = 0;
|
|
17
|
+
for (const n of freq.values()) { const p = n / s.length; h -= p * Math.log2(p); }
|
|
18
|
+
return h;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KEY_NAME = String.raw`[A-Za-z0-9_.-]*(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|credential|access[_-]?key|private[_-]?key|client[_-]?secret)[A-Za-z0-9_.-]*`;
|
|
22
|
+
|
|
23
|
+
export const SECRET_RULES = [
|
|
24
|
+
{ id: "private-key-block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
25
|
+
{ id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
26
|
+
{ id: "bearer-token", re: /\bBearer\s+[A-Za-z0-9\-_.=]{20,}/g },
|
|
27
|
+
{ id: "connection-string-with-credentials", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@<>]+:[^\s:/@<>]+@[^\s]+/gi },
|
|
28
|
+
// KEY=value / key: "value" where the key name looks secret-bearing and the value is non-trivial
|
|
29
|
+
{ id: "keyed-secret-assignment", re: new RegExp(String.raw`\b(${KEY_NAME})\s*[:=]\s*["']?([^\s"'#,;]{12,})`, "gi"), valueGroup: 2, keyFilter: true },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const IDENTIFIER_LIKE = [
|
|
33
|
+
/^[A-Z0-9]+(?:_[A-Z0-9]+)+$/, // SNAKE_CASE constants / handler names
|
|
34
|
+
/^[a-z0-9]+(?:[-_][a-z0-9]+)+$/, // kebab-case / snake_case words
|
|
35
|
+
/^[A-Za-z]+(?:\/[A-Za-z]+)+$/, // slash-separated words (security/privacy/legal)
|
|
36
|
+
/^[a-f0-9]{32}$|^[a-f0-9]{40}$|^[a-f0-9]{64}$/i, // content hashes / commit SHAs are not secrets
|
|
37
|
+
/^sha(?:1|256|384|512)-[A-Za-z0-9+/]+={0,2}$/, // subresource-integrity hashes (package metadata)
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Paths, slugs and compound names: mostly real words joined by - _ / . (e.g. a-b--c/d.md#e). */
|
|
41
|
+
function isWordy(tok) {
|
|
42
|
+
const parts = tok.split(/[-_/.#=+]+/).filter(Boolean);
|
|
43
|
+
const words = parts.filter((p) => /^[A-Za-z]{3,}$/.test(p));
|
|
44
|
+
return parts.length >= 3 && words.length / parts.length >= 0.6;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when a long token looks like a random secret rather than an identifier. */
|
|
48
|
+
export function isHighEntropyToken(tok) {
|
|
49
|
+
if (tok.length < 32) return false;
|
|
50
|
+
if (IDENTIFIER_LIKE.some((re) => re.test(tok)) || isWordy(tok)) return false;
|
|
51
|
+
if (!/[0-9]/.test(tok) || !/[A-Za-z]/.test(tok)) return false;
|
|
52
|
+
return shannonEntropy(tok) >= 3.8;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PLACEHOLDER_VALUE = /^(<[^>]*>|\$\{[^}]*\}|x{6,}|\*{6,}|changeme|example|your[_-]?\w*|placeholder|redacted.*|\[redacted.*)$/i;
|
|
56
|
+
|
|
57
|
+
/** Find secret-like spans in one line. Returns [{ rule, start, end }]. Never returns the value itself. */
|
|
58
|
+
export function findSecrets(line) {
|
|
59
|
+
const hits = [];
|
|
60
|
+
for (const rule of SECRET_RULES) {
|
|
61
|
+
rule.re.lastIndex = 0;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = rule.re.exec(line))) {
|
|
64
|
+
if (rule.valueGroup) {
|
|
65
|
+
const value = m[rule.valueGroup];
|
|
66
|
+
if (PLACEHOLDER_VALUE.test(value) || /^(true|false|null|none|\d+)$/i.test(value)) continue;
|
|
67
|
+
// code references (token: getToken(), secret = config.secret, short identifiers) are not secrets
|
|
68
|
+
if (IDENTIFIER_LIKE.slice(0, 3).some((re) => re.test(value)) || /[().]/.test(value) || (value.length < 20 && /^[A-Za-z_]+$/.test(value))) continue;
|
|
69
|
+
const start = m.index + m[0].lastIndexOf(value);
|
|
70
|
+
hits.push({ rule: rule.id, start, end: start + value.length });
|
|
71
|
+
} else {
|
|
72
|
+
hits.push({ rule: rule.id, start: m.index, end: m.index + m[0].length });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const tokRe = /[A-Za-z0-9+/_-]{32,}={0,2}/g;
|
|
77
|
+
let t;
|
|
78
|
+
while ((t = tokRe.exec(line))) {
|
|
79
|
+
if (hits.some((h) => t.index < h.end && t.index + t[0].length > h.start)) continue;
|
|
80
|
+
if (isHighEntropyToken(t[0])) hits.push({ rule: "high-entropy-token", start: t.index, end: t.index + t[0].length });
|
|
81
|
+
}
|
|
82
|
+
return hits.sort((a, b) => a.start - b.start);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Redact secret-like spans in text. Returns { text, findings: [{ line, rule }] }. */
|
|
86
|
+
export function redactText(text) {
|
|
87
|
+
const findings = [];
|
|
88
|
+
let inKey = false;
|
|
89
|
+
const lines = text.split("\n").map((line, i) => {
|
|
90
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(line)) { inKey = true; findings.push({ line: i + 1, rule: "private-key-block" }); return "[REDACTED-PRIVATE-KEY]"; }
|
|
91
|
+
if (inKey) { if (/-----END [A-Z ]*PRIVATE KEY-----/.test(line)) inKey = false; return ""; }
|
|
92
|
+
const hits = findSecrets(line);
|
|
93
|
+
if (!hits.length) return line;
|
|
94
|
+
let out = line;
|
|
95
|
+
for (const h of [...hits].reverse()) { out = out.slice(0, h.start) + "[REDACTED-SECRET-LIKE-VALUE]" + out.slice(h.end); findings.push({ line: i + 1, rule: h.rule }); }
|
|
96
|
+
return out;
|
|
97
|
+
});
|
|
98
|
+
return { text: lines.join("\n"), findings: findings.sort((a, b) => a.line - b.line) };
|
|
99
|
+
}
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: project-knowledge-assistant
|
|
3
|
-
description:
|
|
4
|
-
Answer questions about a target project strictly from the evidence collected by
|
|
5
|
-
project-documentation-discovery (and, where applicable, software-reverse-engineering), always
|
|
6
|
-
citing source_location, and clearly separating documented facts from inference. Use this skill
|
|
7
|
-
whenever a user asks "what does the system do", "how does X work", or any question about the
|
|
8
|
-
target project, after an evidence index exists. Never answers from general knowledge about similar
|
|
9
|
-
systems as if it were specific to this project.
|
|
3
|
+
description: "Answer questions about a target project strictly from the evidence collected by project-documentation-discovery (and, where applicable, software-reverse-engineering), always citing source_location, and clearly separating documented facts from inference. Use this skill whenever a user asks 'what does the system do', 'how does X work', or any question about the target project, after an evidence index exists. Never answers from general knowledge about similar systems as if it were specific to this project."
|
|
10
4
|
metadata:
|
|
11
5
|
version: 1.0.0
|
|
12
6
|
partOf: enterprise-project-intelligence-agent
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: software-change-impact-analyzer
|
|
3
|
-
description:
|
|
4
|
-
Trace the likely effects of a proposed change across modules, APIs, workflows, tests, data
|
|
5
|
-
entities, and documentation, using evidence from software-reverse-engineering and
|
|
6
|
-
project-documentation-discovery. Use this skill before implementing a non-trivial change, when
|
|
7
|
-
estimating risk/effort, or when a user asks "what would break if we changed X".
|
|
3
|
+
description: "Trace the likely effects of a proposed change across modules, APIs, workflows, tests, data entities, and documentation, using evidence from software-reverse-engineering and project-documentation-discovery. Use this skill before implementing a non-trivial change, when estimating risk/effort, or when a user asks 'what would break if we changed X'."
|
|
8
4
|
metadata:
|
|
9
5
|
version: 1.0.0
|
|
10
6
|
partOf: enterprise-project-intelligence-agent
|
|
@@ -22,23 +18,30 @@ the available documentation/code actually reveals, rather than a confident-sound
|
|
|
22
18
|
## Required inputs
|
|
23
19
|
|
|
24
20
|
- A description of the proposed change (what module/API/data entity/workflow it touches).
|
|
25
|
-
-
|
|
21
|
+
- `system-map.json` from `software-reverse-engineering`
|
|
22
|
+
(schema: `skills/software-reverse-engineering/references/system-map-schema.md`) when it exists.
|
|
23
|
+
If it does not, build the minimal map you need directly from the code and documentation (modules,
|
|
24
|
+
routes, entities, tests that reference the target) and state in the report that no system map was
|
|
25
|
+
available.
|
|
26
26
|
- Evidence index from `project-documentation-discovery`.
|
|
27
27
|
|
|
28
28
|
## Execution procedure
|
|
29
29
|
|
|
30
30
|
1. **Identify the direct target** (the module/API/entity named in the change) and confirm it exists
|
|
31
31
|
in the evidence, citing it.
|
|
32
|
-
2. **
|
|
32
|
+
2. **Search exhaustively for references to the target** (the route string, entity/field name,
|
|
33
|
+
function name, and its documented spelling variants such as `{id}` vs `:id`) across code, tests,
|
|
34
|
+
configuration, and documentation; every hit is a candidate impact.
|
|
35
|
+
3. **Trace direct dependents** using `references/impact-dimensions.md`: callers of a changed API,
|
|
33
36
|
consumers of a changed data entity, UI components bound to a changed route, tests exercising the
|
|
34
37
|
changed code, and documentation describing the changed behavior.
|
|
35
|
-
|
|
38
|
+
4. **Trace indirect/second-order effects** only as far as evidence supports (e.g., a downstream
|
|
36
39
|
service that itself calls the directly affected service); label second-order effects as
|
|
37
40
|
`likely, lower confidence` rather than equally certain to first-order effects.
|
|
38
|
-
|
|
41
|
+
5. **Classify severity per impacted item**: `breaking` (contract changes), `behavioral`
|
|
39
42
|
(output/behavior changes without a contract break), `cosmetic`, or `unknown-needs-investigation`.
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
6. **List tests and documentation that must be updated**, not just code.
|
|
44
|
+
7. **State what could not be assessed** (e.g., parts of the system outside the available evidence)
|
|
42
45
|
explicitly, rather than implying full coverage.
|
|
43
46
|
|
|
44
47
|
## Expected outputs
|
|
@@ -71,3 +74,4 @@ reference, a documented workflow step).
|
|
|
71
74
|
## References
|
|
72
75
|
|
|
73
76
|
- `references/impact-dimensions.md`
|
|
77
|
+
- `skills/software-reverse-engineering/references/system-map-schema.md` — input artifact format
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: software-reverse-engineering
|
|
3
|
-
description:
|
|
4
|
-
Reconstruct a target system's architecture, modules, APIs, data model, workflows, and external
|
|
5
|
-
dependencies from available evidence (documentation and/or source code), clearly separating facts
|
|
6
|
-
verified in code from facts only documented and from inferences. Use this skill after
|
|
7
|
-
project-documentation-discovery when the user needs an architecture picture, a module map, a data
|
|
8
|
-
model, or a workflow reconstruction, especially when documentation is incomplete, outdated, or the
|
|
9
|
-
codebase is only partially available.
|
|
3
|
+
description: "Reconstruct a target system's architecture, modules, APIs, data model, workflows, and external dependencies from available evidence (documentation and/or source code), clearly separating facts verified in code from facts only documented and from inferences. Use this skill after project-documentation-discovery when the user needs an architecture picture, a module map, a data model, or a workflow reconstruction, especially when documentation is incomplete, outdated, or the codebase is only partially available."
|
|
10
4
|
metadata:
|
|
11
5
|
version: 1.0.0
|
|
12
6
|
partOf: enterprise-project-intelligence-agent
|
|
13
7
|
references:
|
|
14
8
|
- references/reconstruction-method.md
|
|
15
9
|
- references/architecture-diagram-notation.md
|
|
10
|
+
- references/system-map-schema.md
|
|
16
11
|
---
|
|
17
12
|
|
|
18
13
|
# software-reverse-engineering
|
|
@@ -45,17 +40,25 @@ what cannot be determined from the materials provided.
|
|
|
45
40
|
the reconstruction as `documented-only, not code-verified`.
|
|
46
41
|
5. **Identify external dependencies** (third-party services, libraries with material behavioral
|
|
47
42
|
impact, infrastructure dependencies) from manifests/config and documentation.
|
|
48
|
-
6. **
|
|
43
|
+
6. **Cross-check every documented item against code.** For each endpoint, route, health check,
|
|
44
|
+
scheduled job, setting, or procedure mentioned in documentation (including runbooks and READMEs,
|
|
45
|
+
not only API references), confirm it exists in the inspected code. Record each one that does not
|
|
46
|
+
as a `documented-not-implemented` discrepancy, and each implemented item missing from the docs
|
|
47
|
+
as `implemented-not-documented`.
|
|
48
|
+
7. **Reconcile conflicts.** When documentation and code disagree, record both, mark the code as
|
|
49
49
|
generally more authoritative for *current* behavior, and flag the documentation as outdated —
|
|
50
50
|
but do not discard the documentation; it may describe intended/future behavior.
|
|
51
|
-
|
|
51
|
+
8. **Record architectural decisions and security boundaries** found in ADRs, comments, or
|
|
52
52
|
documentation; do not infer a security boundary that is not actually enforced in code if code is
|
|
53
53
|
available to check.
|
|
54
|
-
|
|
54
|
+
9. **State explicit limitations** — list what could not be verified due to missing/partial code,
|
|
55
55
|
obfuscation, or unavailable services, rather than presenting a best-guess architecture as complete.
|
|
56
56
|
|
|
57
57
|
## Expected outputs
|
|
58
58
|
|
|
59
|
+
- `system-map.json` following `references/system-map-schema.md` (written to the user's private
|
|
60
|
+
working directory) — the machine-readable map other skills consume.
|
|
61
|
+
|
|
59
62
|
- Module/subsystem map with evidence citations.
|
|
60
63
|
- API/route inventory with verification status.
|
|
61
64
|
- Data model summary with relationship evidence.
|
|
@@ -97,3 +100,4 @@ a citation for the first two and a stated rationale for the third.
|
|
|
97
100
|
|
|
98
101
|
- `references/reconstruction-method.md`
|
|
99
102
|
- `references/architecture-diagram-notation.md`
|
|
103
|
+
- `references/system-map-schema.md` — shared `system-map.json` artifact format
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# system-map.json schema (shared artifact)
|
|
2
|
+
|
|
3
|
+
`software-reverse-engineering` writes this file next to its report (in the user's private working
|
|
4
|
+
directory, e.g. `.local-knowledge/<project-id>/system-map.json`). `software-change-impact-analyzer`
|
|
5
|
+
and `engineering-knowledge-transfer` read it instead of re-deriving the architecture.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"generatedFrom": "evidence-index.json + source files inspected (list them)",
|
|
10
|
+
"modules": [{ "id": "mod:tickets-routes", "name": "Ticket routes", "location": "src/tickets.js#L4-L19", "status": "verified-in-code" }],
|
|
11
|
+
"apis": [{ "id": "api:GET /tickets/:id", "method": "GET", "path": "/tickets/:id", "auth": "agent",
|
|
12
|
+
"implementedAt": "src/tickets.js#L10", "documentedAt": "docs/api.md#endpoints",
|
|
13
|
+
"status": "verified-in-code", "testedBy": ["tests/tickets.test.js#L2-L5"] }],
|
|
14
|
+
"dataEntities": [{ "id": "data:Ticket", "fields": ["subject", "body", "status"], "evidence": "src/tickets.js#L6", "status": "verified-in-code" }],
|
|
15
|
+
"dependencies": [{ "id": "dep:db", "kind": "datastore", "evidence": "src/tickets.js#L4", "status": "inferred", "note": "technology unknown" }],
|
|
16
|
+
"edges": [{ "from": "api:GET /tickets/:id", "to": "data:Ticket", "relation": "reads", "evidence": "src/tickets.js#L11", "status": "verified-in-code" }],
|
|
17
|
+
"discrepancies": [{ "kind": "documented-not-implemented", "item": "DELETE /tickets/{id}", "documentedAt": "docs/api.md#endpoints", "codeChecked": ["src/tickets.js"] }],
|
|
18
|
+
"unknowns": ["database technology", "auth implementation"]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Rules
|
|
23
|
+
- `status` is one of `verified-in-code`, `documented-only`, `inferred`.
|
|
24
|
+
- Every entry carries at least one `source_location` (`evidence`, `implementedAt`, or `documentedAt`).
|
|
25
|
+
- `discrepancies[].kind` is one of `documented-not-implemented`, `implemented-not-documented`,
|
|
26
|
+
`value-mismatch`, `behavior-mismatch`.
|
|
27
|
+
- Never add an entry without evidence; record gaps in `unknowns` instead.
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: technical-document-generator
|
|
3
|
-
description:
|
|
4
|
-
Produce consistent technical documentation (Markdown and HTML reliably; DOCX/PPTX only through a
|
|
5
|
-
verified workflow) from project evidence and/or other skills' outputs, following a consistent
|
|
6
|
-
document structure and citation style. Use this skill whenever a user needs a written artifact
|
|
7
|
-
(architecture doc, AI planning doc, onboarding guide, risk register) assembled from evidence
|
|
8
|
-
already gathered by other skills, rather than generating prose from scratch without grounding.
|
|
3
|
+
description: "Produce consistent technical documentation (Markdown and HTML reliably; DOCX/PPTX only through a verified workflow) from project evidence and/or other skills' outputs, following a consistent document structure and citation style. Use this skill whenever a user needs a written artifact (architecture doc, AI planning doc, onboarding guide, risk register) assembled from evidence already gathered by other skills, rather than generating prose from scratch without grounding."
|
|
9
4
|
metadata:
|
|
10
5
|
version: 1.0.0
|
|
11
6
|
partOf: enterprise-project-intelligence-agent
|
|
@@ -37,8 +32,11 @@ dropping evidence links in the process.
|
|
|
37
32
|
repo-root `templates/` directory if one exists for this document type.
|
|
38
33
|
2. **Populate sections from evidence**, preserving every citation attached to the source finding.
|
|
39
34
|
Do not paraphrase away a citation.
|
|
40
|
-
3. **Render to the target format.** For Markdown → HTML,
|
|
41
|
-
(built-in, verified).
|
|
35
|
+
3. **Render to the target format.** For Markdown → HTML, run
|
|
36
|
+
`node scripts/render-markdown-to-html.mjs <in.md> <out.html>` (built-in, verified). It supports
|
|
37
|
+
headings, paragraphs, bullet and numbered lists, blockquotes, tables, fenced and inline code,
|
|
38
|
+
links, bold and italic; it HTML-escapes all text and drops links whose scheme is not http(s),
|
|
39
|
+
mailto, relative, or `#anchor` (e.g. `javascript:`), so untrusted Markdown cannot inject script. For DOCX/PPTX, only claim support if the consuming project has wired in a
|
|
42
40
|
verified exporter; otherwise, hand back a Markdown document plus an explicit note that DOCX/PPTX
|
|
43
41
|
export requires an additional, verified conversion step.
|
|
44
42
|
4. **Validate the result**: well-formed Markdown (no unmatched code fences, consistent heading
|
|
@@ -1,93 +1,103 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
2
|
/**
|
|
4
|
-
* Minimal, dependency-free Markdown -> HTML renderer
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Minimal, dependency-free Markdown -> HTML renderer for the structured documents this toolkit
|
|
4
|
+
* generates: headings, paragraphs, unordered and ordered lists, blockquotes, fenced code, tables,
|
|
5
|
+
* links, bold/italic, and inline code. Not full CommonMark.
|
|
6
|
+
*
|
|
7
|
+
* Safety: all text is HTML-escaped; link URLs are restricted to http(s), mailto, relative paths and
|
|
8
|
+
* #anchors — anything else (javascript:, data:, vbscript:, ...) is rendered as plain text, so
|
|
9
|
+
* model- or user-supplied Markdown cannot inject script. Verified by tests/markdown-render.test.mjs.
|
|
8
10
|
*/
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
const escapeHtml = (t) => t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
14
|
+
|
|
15
|
+
export function isSafeUrl(url) {
|
|
16
|
+
const u = url.trim().replace(/[\u0000-\u001F\u007F\s]+/g, "");
|
|
17
|
+
if (/^(https?:|mailto:)/i.test(u)) return true;
|
|
18
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(u)) return false; // any other scheme (javascript:, data:, file:, ...)
|
|
19
|
+
return true; // relative path, ./x, ../x, /x, #anchor
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Inline formatting with code spans protected from emphasis/link processing. */
|
|
23
|
+
function inline(text) {
|
|
24
|
+
const codes = [];
|
|
25
|
+
let t = text.replace(/`([^`]+)`/g, (_, c) => { codes.push(c); return `\u0000${codes.length - 1}\u0000`; });
|
|
26
|
+
t = escapeHtml(t);
|
|
27
|
+
t = t.replace(/\[([^\]]+)\]\(([^()\s]+(?:\([^()\s]*\))?[^()\s]*)\)/g, (m, label, url) => {
|
|
28
|
+
const raw = url.replace(/&/g, "&").replace(/"/g, '"');
|
|
29
|
+
return isSafeUrl(raw) ? `<a href="${escapeHtml(raw)}">${label}</a>` : `${label} (unsafe link removed)`;
|
|
30
|
+
});
|
|
31
|
+
t = t.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/(^|[^\w*])\*(?!\s)(.+?)\*(?!\w)/g, "$1<em>$2</em>");
|
|
32
|
+
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escapeHtml(codes[Number(i)])}</code>`);
|
|
33
|
+
}
|
|
34
|
+
|
|
9
35
|
export function renderMarkdownToHtml(md) {
|
|
10
|
-
const lines = md.split("\n");
|
|
11
|
-
|
|
36
|
+
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
|
37
|
+
const out = [];
|
|
38
|
+
let list = null; // "ul" | "ol"
|
|
39
|
+
let table = null; // row count
|
|
40
|
+
let quote = []; // pending blockquote lines
|
|
12
41
|
let inCode = false;
|
|
13
|
-
let inList = false;
|
|
14
|
-
let inTable = false;
|
|
15
|
-
let tableRow = 0;
|
|
16
|
-
|
|
17
|
-
const inline = (text) =>
|
|
18
|
-
text
|
|
19
|
-
.replace(/&/g, "&")
|
|
20
|
-
.replace(/</g, "<")
|
|
21
|
-
.replace(/>/g, ">")
|
|
22
|
-
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
23
|
-
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
24
|
-
.replace(/`(.+?)`/g, "<code>$1</code>")
|
|
25
|
-
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
|
|
26
42
|
|
|
27
|
-
const closeList = () => { if (
|
|
28
|
-
const closeTable = () => { if (
|
|
43
|
+
const closeList = () => { if (list) { out.push(`</${list}>`); list = null; } };
|
|
44
|
+
const closeTable = () => { if (table !== null) { out.push("</table>"); table = null; } };
|
|
45
|
+
const closeQuote = () => { if (quote.length) { out.push(`<blockquote>${renderMarkdownToHtml(quote.join("\n")).match(/<body>\n([\s\S]*)<\/body>/)[1].trim()}</blockquote>`); quote = []; } };
|
|
46
|
+
const closeAll = () => { closeList(); closeTable(); closeQuote(); };
|
|
29
47
|
|
|
30
|
-
for (const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
html += inCode ? "</code></pre>\n" : "<pre><code>";
|
|
35
|
-
inCode = !inCode;
|
|
36
|
-
continue;
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
50
|
+
if (!inCode) { closeAll(); out.push("<pre><code>"); } else out.push("</code></pre>");
|
|
51
|
+
inCode = !inCode; continue;
|
|
37
52
|
}
|
|
38
|
-
if (inCode) {
|
|
53
|
+
if (inCode) { out.push(escapeHtml(line)); continue; }
|
|
39
54
|
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
55
|
+
const bq = /^\s*>\s?(.*)$/.exec(line);
|
|
56
|
+
if (bq) { closeList(); closeTable(); quote.push(bq[1]); continue; }
|
|
57
|
+
closeQuote();
|
|
58
|
+
|
|
59
|
+
const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
60
|
+
if (heading) { closeAll(); const l = heading[1].length; out.push(`<h${l}>${inline(heading[2])}</h${l}>`); continue; }
|
|
47
61
|
|
|
48
|
-
const
|
|
49
|
-
|
|
62
|
+
const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
63
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
64
|
+
if (ul || ol) {
|
|
50
65
|
closeTable();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
continue;
|
|
66
|
+
const kind = ul ? "ul" : "ol";
|
|
67
|
+
if (list !== kind) { closeList(); out.push(`<${kind}>`); list = kind; }
|
|
68
|
+
out.push(`<li>${inline((ul || ol)[1])}</li>`); continue;
|
|
54
69
|
}
|
|
70
|
+
if (list && /^\s{2,}\S/.test(line)) { out[out.length - 1] = out[out.length - 1].replace(/<\/li>$/, ` ${inline(line.trim())}</li>`); continue; }
|
|
55
71
|
closeList();
|
|
56
72
|
|
|
57
|
-
const tableLine = /^\|(.+)
|
|
73
|
+
const tableLine = /^\|(.+)\|\s*$/.exec(line.trim());
|
|
58
74
|
if (tableLine) {
|
|
59
75
|
const cells = tableLine[1].split("|").map((c) => c.trim());
|
|
60
|
-
if (cells.every((c) =>
|
|
61
|
-
if (
|
|
62
|
-
const tag =
|
|
63
|
-
|
|
64
|
-
tableRow++;
|
|
76
|
+
if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row
|
|
77
|
+
if (table === null) { out.push("<table>"); table = 0; }
|
|
78
|
+
const tag = table === 0 ? "th" : "td";
|
|
79
|
+
out.push(`<tr>${cells.map((c) => `<${tag}>${inline(c)}</${tag}>`).join("")}</tr>`); table++;
|
|
65
80
|
continue;
|
|
66
81
|
}
|
|
67
82
|
closeTable();
|
|
68
83
|
|
|
69
|
-
if (
|
|
70
|
-
|
|
84
|
+
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push("<hr>"); continue; }
|
|
85
|
+
if (line.trim() === "") { out.push(""); continue; }
|
|
86
|
+
out.push(`<p>${inline(line)}</p>`);
|
|
71
87
|
}
|
|
72
|
-
|
|
73
|
-
if (inCode)
|
|
74
|
-
|
|
75
|
-
return `<!DOCTYPE html>\n<html><head><meta charset="utf-8"></head><body>\n${html}</body></html>\n`;
|
|
88
|
+
closeAll();
|
|
89
|
+
if (inCode) out.push("</code></pre>");
|
|
90
|
+
return `<!DOCTYPE html>\n<html><head><meta charset="utf-8"></head><body>\n${out.join("\n")}\n</body></html>\n`;
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
async function main() {
|
|
79
94
|
const [inPath, outPath] = process.argv.slice(2);
|
|
80
|
-
if (!inPath) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const html = renderMarkdownToHtml(md);
|
|
87
|
-
if (outPath) fs.writeFileSync(outPath, html);
|
|
88
|
-
else console.log(html);
|
|
95
|
+
if (!inPath) { console.error("Usage: node render-markdown-to-html.mjs <input.md> [output.html]"); process.exit(1); }
|
|
96
|
+
try {
|
|
97
|
+
const fs = await import("node:fs");
|
|
98
|
+
const html = renderMarkdownToHtml(fs.readFileSync(inPath, "utf8"));
|
|
99
|
+
if (outPath) fs.writeFileSync(outPath, html); else process.stdout.write(html);
|
|
100
|
+
} catch (err) { console.error(`render-markdown-to-html: ${err.message}`); process.exit(1); }
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
92
|
-
main();
|
|
93
|
-
}
|
|
103
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|