@thanbir-tamim/enterprise-project-intelligence-agent 0.1.0 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +23 -2
  2. package/README.md +302 -46
  3. package/assets/figures/feasibility-scoring-engine.png +0 -0
  4. package/assets/figures/hero-architecture.png +0 -0
  5. package/assets/figures/skills-interaction-map.png +0 -0
  6. package/docs/INSTALLATION.md +6 -6
  7. package/docs/SUPPORTED_FORMATS.md +4 -4
  8. package/examples/synthetic-inventory-system/ai-opportunity-matrix/AI_OPPORTUNITY_MATRIX.md +4 -4
  9. package/examples/synthetic-inventory-system/generated-docs/INVENTORY_SUMMARY.md +17 -0
  10. package/examples/synthetic-inventory-system/generated-docs/evidence-index.json +78 -0
  11. package/examples/synthetic-inventory-system/generated-docs/inventory.json +26 -0
  12. package/examples/synthetic-inventory-system/presentation/DEMO_DECK.md +69 -55
  13. package/examples/synthetic-inventory-system/presentation/demo-deck.json +194 -16
  14. package/package.json +2 -1
  15. package/scripts/cli.mjs +20 -14
  16. package/scripts/lib/skills.mjs +1 -1
  17. package/scripts/release-audit.mjs +73 -70
  18. package/skills/corporate-presentation-generator/SKILL.md +49 -23
  19. package/skills/corporate-presentation-generator/scripts/outline-to-deck-json.mjs +77 -0
  20. package/skills/documentation-quality-auditor/SKILL.md +28 -21
  21. package/skills/documentation-quality-auditor/references/audit-checks.md +15 -8
  22. package/skills/documentation-quality-auditor/scripts/audit-docs.mjs +192 -32
  23. package/skills/engineering-knowledge-transfer/SKILL.md +1 -5
  24. package/skills/enterprise-ai-integration-planner/SKILL.md +13 -15
  25. package/skills/enterprise-ai-integration-planner/references/feasibility-scoring.md +26 -5
  26. package/skills/enterprise-ai-integration-planner/scripts/score-opportunity.mjs +32 -15
  27. package/skills/presentation-compliance-validator/SKILL.md +15 -15
  28. package/skills/presentation-compliance-validator/references/rule-schema.md +67 -10
  29. package/skills/presentation-compliance-validator/scripts/validate-deck.mjs +48 -11
  30. package/skills/project-documentation-discovery/SKILL.md +25 -8
  31. package/skills/project-documentation-discovery/references/format-matrix.md +4 -4
  32. package/skills/project-documentation-discovery/scripts/inventory.mjs +174 -100
  33. package/skills/project-documentation-discovery/scripts/secrets.mjs +99 -0
  34. package/skills/project-knowledge-assistant/SKILL.md +1 -7
  35. package/skills/software-change-impact-analyzer/SKILL.md +15 -11
  36. package/skills/software-reverse-engineering/SKILL.md +14 -10
  37. package/skills/software-reverse-engineering/references/system-map-schema.md +27 -0
  38. package/skills/technical-document-generator/SKILL.md +6 -8
  39. 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
- - Architecture/data-model evidence from `software-reverse-engineering`.
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. **Trace direct dependents** using `references/impact-dimensions.md`: callers of a changed API,
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
- 3. **Trace indirect/second-order effects** only as far as evidence supports (e.g., a downstream
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
- 4. **Classify severity per impacted item**: `breaking` (contract changes), `behavioral`
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
- 5. **List tests and documentation that must be updated**, not just code.
41
- 6. **State what could not be assessed** (e.g., parts of the system outside the available evidence)
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. **Reconcile conflicts.** When documentation and code disagree, record both, mark the code as
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
- 7. **Record architectural decisions and security boundaries** found in ADRs, comments, or
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
- 8. **State explicit limitations** — list what could not be verified due to missing/partial code,
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, use `scripts/render-markdown-to-html.mjs`
41
- (built-in, verified). For DOCX/PPTX, only claim support if the consuming project has wired in a
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 covering headings, paragraphs, lists,
5
- * code fences, links, bold/italic, and tables. Intentionally conservative: it does not attempt
6
- * full CommonMark compliance, only enough to produce valid, well-formed HTML for the structured
7
- * documents this toolkit generates. Verified by tests/render.test.mjs.
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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(/&amp;/g, "&").replace(/&quot;/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
- let html = "";
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, "&amp;")
20
- .replace(/</g, "&lt;")
21
- .replace(/>/g, "&gt;")
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 (inList) { html += "</ul>\n"; inList = false; } };
28
- const closeTable = () => { if (inTable) { html += "</table>\n"; inTable = false; tableRow = 0; } };
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 raw of lines) {
31
- const line = raw;
32
- if (line.trim().startsWith("```")) {
33
- closeList(); closeTable();
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) { html += inline(line) + "\n"; continue; }
53
+ if (inCode) { out.push(escapeHtml(line)); continue; }
39
54
 
40
- const heading = /^(#{1,6})\s+(.*)$/.exec(line);
41
- if (heading) {
42
- closeList(); closeTable();
43
- const level = heading[1].length;
44
- html += `<h${level}>${inline(heading[2])}</h${level}>\n`;
45
- continue;
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 listItem = /^[-*]\s+(.*)$/.exec(line);
49
- if (listItem) {
62
+ const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
63
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
64
+ if (ul || ol) {
50
65
  closeTable();
51
- if (!inList) { html += "<ul>\n"; inList = true; }
52
- html += `<li>${inline(listItem[1])}</li>\n`;
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 = /^\|(.+)\|$/.exec(line.trim());
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) => /^-+$/.test(c))) continue; // separator row
61
- if (!inTable) { html += "<table>\n"; inTable = true; tableRow = 0; }
62
- const tag = tableRow === 0 ? "th" : "td";
63
- html += `<tr>${cells.map((c) => `<${tag}>${inline(c)}</${tag}>`).join("")}</tr>\n`;
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 (line.trim() === "") { html += "\n"; continue; }
70
- html += `<p>${inline(line)}</p>\n`;
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
- closeList(); closeTable();
73
- if (inCode) html += "</code></pre>\n";
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
- console.error("Usage: node render-markdown-to-html.mjs <input.md> [output.html]");
82
- process.exit(1);
83
- }
84
- const fs = await import("node:fs");
85
- const md = fs.readFileSync(inPath, "utf8");
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();