tadforce 0.0.0-stage → 0.3.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 (141) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +202 -0
  4. package/NOTICE +11 -0
  5. package/README.md +266 -2
  6. package/assets/logo-mark.svg +24 -0
  7. package/assets/logo.svg +31 -0
  8. package/bin/tadforce.js +4 -0
  9. package/commands/tadforce-review.md +8 -0
  10. package/commands/tadforce-update.md +6 -0
  11. package/commands/tadforce.md +10 -0
  12. package/package.json +60 -4
  13. package/skills/tadforce/SKILL.md +73 -0
  14. package/skills/tadforce/references/axes-rules.md +29 -0
  15. package/skills/tadforce/references/evidence-rules.md +40 -0
  16. package/skills/tadforce/references/exclusion-heuristics.md +40 -0
  17. package/skills/tadforce/references/figure-rules.md +42 -0
  18. package/skills/tadforce/references/markdown-conventions.md +67 -0
  19. package/skills/tadforce/references/modules/agentforce-ai.md +24 -0
  20. package/skills/tadforce/references/modules/analytics.md +19 -0
  21. package/skills/tadforce/references/modules/commerce.md +25 -0
  22. package/skills/tadforce/references/modules/data-cloud.md +23 -0
  23. package/skills/tadforce/references/modules/experience-cloud.md +24 -0
  24. package/skills/tadforce/references/modules/field-service.md +25 -0
  25. package/skills/tadforce/references/modules/industries.md +21 -0
  26. package/skills/tadforce/references/modules/integration-platforms.md +19 -0
  27. package/skills/tadforce/references/modules/marketing.md +24 -0
  28. package/skills/tadforce/references/modules/omnistudio.md +24 -0
  29. package/skills/tadforce/references/modules/platform-events.md +19 -0
  30. package/skills/tadforce/references/modules/revenue-cpq.md +25 -0
  31. package/skills/tadforce/references/modules/sales-cloud.md +26 -0
  32. package/skills/tadforce/references/modules/security-shield.md +18 -0
  33. package/skills/tadforce/references/modules/service-cloud.md +27 -0
  34. package/skills/tadforce/references/naming-external-systems.md +32 -0
  35. package/skills/tadforce/references/questions.md +59 -0
  36. package/skills/tadforce/references/salesforce-skills.md +27 -0
  37. package/skills/tadforce/references/workflow.md +95 -0
  38. package/skills/tadforce/references/writing-rules.md +47 -0
  39. package/skills/tadforce-review/SKILL.md +53 -0
  40. package/skills/tadforce-update/SKILL.md +48 -0
  41. package/src/build/brand.js +107 -0
  42. package/src/build/browser.js +70 -0
  43. package/src/build/directives.js +293 -0
  44. package/src/build/export.js +60 -0
  45. package/src/build/figures.js +119 -0
  46. package/src/build/mermaid.js +107 -0
  47. package/src/build/render.js +299 -0
  48. package/src/build/serve.js +59 -0
  49. package/src/cli.js +68 -0
  50. package/src/commands/docs.js +204 -0
  51. package/src/commands/doctor.js +370 -0
  52. package/src/commands/init.js +132 -0
  53. package/src/commands/org.js +151 -0
  54. package/src/commands/review.js +35 -0
  55. package/src/commands/spec.js +91 -0
  56. package/src/commands/update.js +154 -0
  57. package/src/facts/extractors/apex.js +111 -0
  58. package/src/facts/extractors/automation.js +85 -0
  59. package/src/facts/extractors/data.js +94 -0
  60. package/src/facts/extractors/integration.js +76 -0
  61. package/src/facts/extractors/misc.js +100 -0
  62. package/src/facts/extractors/security.js +92 -0
  63. package/src/facts/extractors/ui.js +120 -0
  64. package/src/facts/fingerprints.js +32 -0
  65. package/src/facts/heuristics/exclusions.js +143 -0
  66. package/src/facts/heuristics/modules.js +85 -0
  67. package/src/facts/heuristics/naming.js +31 -0
  68. package/src/facts/heuristics/signals.js +76 -0
  69. package/src/facts/index.js +197 -0
  70. package/src/facts/org.js +87 -0
  71. package/src/facts/xml.js +85 -0
  72. package/src/sf/sf.js +120 -0
  73. package/src/util/args.js +21 -0
  74. package/src/util/config.js +104 -0
  75. package/src/util/fsx.js +58 -0
  76. package/src/util/i18n.js +49 -0
  77. package/src/util/log.js +31 -0
  78. package/src/util/paths.js +11 -0
  79. package/src/validate/index.js +126 -0
  80. package/templates/axes/en/00-document-control.md +3 -0
  81. package/templates/axes/en/01-summary.md +7 -0
  82. package/templates/axes/en/02-method.md +9 -0
  83. package/templates/axes/en/03-findings.md +19 -0
  84. package/templates/axes/en/04-register.md +3 -0
  85. package/templates/axes/en/05-roadmap.md +3 -0
  86. package/templates/axes/en/99-appendix.md +5 -0
  87. package/templates/axes/fr/00-document-control.md +3 -0
  88. package/templates/axes/fr/01-summary.md +7 -0
  89. package/templates/axes/fr/02-method.md +9 -0
  90. package/templates/axes/fr/03-findings.md +19 -0
  91. package/templates/axes/fr/04-register.md +3 -0
  92. package/templates/axes/fr/05-roadmap.md +3 -0
  93. package/templates/axes/fr/99-appendix.md +5 -0
  94. package/templates/html/cover.html +12 -0
  95. package/templates/html/editor.js +502 -0
  96. package/templates/html/style.css +255 -0
  97. package/templates/html/template.html +88 -0
  98. package/templates/i18n/en.json +366 -0
  99. package/templates/i18n/fr.json +366 -0
  100. package/templates/manifest/package-tad.xml +543 -0
  101. package/templates/specs/sections.yml +494 -0
  102. package/templates/tad/en/00-document-control.md +7 -0
  103. package/templates/tad/en/01-summary.md +17 -0
  104. package/templates/tad/en/02-purpose.md +35 -0
  105. package/templates/tad/en/03-context.md +17 -0
  106. package/templates/tad/en/04-general.md +29 -0
  107. package/templates/tad/en/05-functional.md +7 -0
  108. package/templates/tad/en/06-data.md +71 -0
  109. package/templates/tad/en/07-application.md +49 -0
  110. package/templates/tad/en/08-ui.md +41 -0
  111. package/templates/tad/en/09-integrations.md +53 -0
  112. package/templates/tad/en/10-security.md +59 -0
  113. package/templates/tad/en/11-reporting.md +13 -0
  114. package/templates/tad/en/12-operations.md +25 -0
  115. package/templates/tad/en/13-nfr.md +9 -0
  116. package/templates/tad/en/14-decisions.md +3 -0
  117. package/templates/tad/en/15-outlook.md +3 -0
  118. package/templates/tad/en/99-appendices.md +119 -0
  119. package/templates/tad/fr/00-document-control.md +7 -0
  120. package/templates/tad/fr/01-summary.md +17 -0
  121. package/templates/tad/fr/02-purpose.md +35 -0
  122. package/templates/tad/fr/03-context.md +17 -0
  123. package/templates/tad/fr/04-general.md +29 -0
  124. package/templates/tad/fr/05-functional.md +7 -0
  125. package/templates/tad/fr/06-data.md +71 -0
  126. package/templates/tad/fr/07-application.md +49 -0
  127. package/templates/tad/fr/08-ui.md +41 -0
  128. package/templates/tad/fr/09-integrations.md +53 -0
  129. package/templates/tad/fr/10-security.md +59 -0
  130. package/templates/tad/fr/11-reporting.md +13 -0
  131. package/templates/tad/fr/12-operations.md +25 -0
  132. package/templates/tad/fr/13-nfr.md +9 -0
  133. package/templates/tad/fr/14-decisions.md +3 -0
  134. package/templates/tad/fr/15-outlook.md +3 -0
  135. package/templates/tad/fr/99-appendices.md +119 -0
  136. package/templates/vendor/LICENSE-html-docx-js.txt +21 -0
  137. package/templates/vendor/LICENSE-html2canvas.txt +22 -0
  138. package/templates/vendor/LICENSE-mermaid.txt +21 -0
  139. package/templates/vendor/html-docx.js +13216 -0
  140. package/templates/vendor/html2canvas.min.js +20 -0
  141. package/templates/vendor/mermaid.min.js +2314 -0
@@ -0,0 +1,107 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { TEMPLATES } from '../util/paths.js';
5
+ import { getBrowser } from './browser.js';
6
+
7
+ // Shared colour classes for flowcharts: actors, Salesforce, external systems, data, warnings.
8
+ export const CLASSDEFS = `
9
+ classDef actor fill:#F1E8FF,stroke:#7B3FE4,color:#2A1458,stroke-width:1.2px
10
+ classDef sf fill:#E8F1FF,stroke:#2F6FE4,color:#0B2545,stroke-width:1.2px
11
+ classDef ext fill:#FFF2E2,stroke:#E0861F,color:#4A2A05,stroke-width:1.2px
12
+ classDef data fill:#E7F6EC,stroke:#2E9E5B,color:#0E3B22,stroke-width:1.2px
13
+ classDef warn fill:#FDECEF,stroke:#D6336C,color:#5A0A23,stroke-width:1.2px
14
+ `;
15
+
16
+ export const MERMAID_CFG = {
17
+ startOnLoad: false,
18
+ theme: 'base',
19
+ securityLevel: 'loose',
20
+ htmlLabels: false,
21
+ fontFamily: 'Segoe UI, Arial, sans-serif',
22
+ themeVariables: {
23
+ fontFamily: 'Segoe UI, Arial, sans-serif',
24
+ fontSize: '14px',
25
+ primaryColor: '#E8F1FF',
26
+ primaryBorderColor: '#2F6FE4',
27
+ primaryTextColor: '#0B2545',
28
+ lineColor: '#5B6B7D',
29
+ secondaryColor: '#F1E8FF',
30
+ tertiaryColor: '#F6F8FB',
31
+ clusterBkg: '#F7F9FC',
32
+ clusterBorder: '#C9D4E3',
33
+ actorBkg: '#E8F1FF',
34
+ actorBorder: '#2F6FE4',
35
+ actorTextColor: '#0B2545',
36
+ signalColor: '#33475B',
37
+ signalTextColor: '#1B2632',
38
+ labelBoxBkgColor: '#F1E8FF',
39
+ labelBoxBorderColor: '#7B3FE4',
40
+ noteBkgColor: '#FFF8E1',
41
+ noteBorderColor: '#E0B000',
42
+ activationBkgColor: '#DCE8FF',
43
+ sequenceNumberColor: '#FFFFFF',
44
+ edgeLabelBackground: '#FFFFFF',
45
+ },
46
+ flowchart: { htmlLabels: false, curve: 'basis', padding: 12, nodeSpacing: 40, rankSpacing: 50 },
47
+ sequence: { showSequenceNumbers: true, actorMargin: 40, messageFontSize: 13, wrap: true, width: 160 },
48
+ er: { fontSize: 13, layoutDirection: 'LR' },
49
+ state: { fontSize: 13 },
50
+ };
51
+
52
+ const MERMAID_JS = path.join(TEMPLATES, 'vendor', 'mermaid.min.js');
53
+ const CFG_HASH = crypto.createHash('sha1').update(JSON.stringify(MERMAID_CFG) + fs.statSync(MERMAID_JS).size).digest('hex').slice(0, 8);
54
+
55
+ export const figureHash = (code) => crypto.createHash('sha1').update(CFG_HASH + code).digest('hex').slice(0, 16);
56
+
57
+ /** Render Mermaid figures to SVG strings with a headless browser, using a cache keyed by content. */
58
+ export async function renderMermaid(figures, cacheDir) {
59
+ if (!figures.length) return figures;
60
+ fs.mkdirSync(cacheDir, { recursive: true });
61
+ const todo = [];
62
+ for (const f of figures) {
63
+ f.hash = figureHash(f.code);
64
+ const p = path.join(cacheDir, f.hash + '.svg');
65
+ if (fs.existsSync(p)) f.svg = fs.readFileSync(p, 'utf8');
66
+ else todo.push(f);
67
+ }
68
+ if (!todo.length) return figures;
69
+ const browser = await getBrowser();
70
+ const page = await browser.newPage();
71
+ try {
72
+ await page.setViewport({ width: 1400, height: 900 });
73
+ await page.setContent('<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:"Segoe UI",Arial,sans-serif;width:1400px}</style></head><body></body></html>');
74
+ await page.addScriptTag({ path: MERMAID_JS });
75
+ const results = await page.evaluate(async (codes, cfg) => {
76
+ // eslint-disable-next-line no-undef
77
+ mermaid.initialize(cfg);
78
+ const out = [];
79
+ for (let i = 0; i < codes.length; i++) {
80
+ try {
81
+ // eslint-disable-next-line no-undef
82
+ const r = await mermaid.render('tadfig' + i, codes[i]);
83
+ out.push({ svg: r.svg });
84
+ } catch (e) {
85
+ out.push({ error: String((e && e.message) || e) });
86
+ document.querySelectorAll('[id^="dtadfig"]').forEach((n) => n.remove());
87
+ }
88
+ }
89
+ return out;
90
+ }, todo.map((f) => f.code), MERMAID_CFG);
91
+ todo.forEach((f, i) => {
92
+ const r = results[i] || { error: 'no output' };
93
+ if (r.error || !/<svg/.test(r.svg || '')) {
94
+ f.error = (r.error || 'invalid SVG').slice(0, 400);
95
+ f.svg = `<pre class="mmd-error">${escapeHtml(f.error)}\n\n${escapeHtml(f.code.slice(0, 600))}</pre>`;
96
+ return;
97
+ }
98
+ f.svg = r.svg;
99
+ fs.writeFileSync(path.join(cacheDir, f.hash + '.svg'), r.svg, 'utf8');
100
+ });
101
+ } finally {
102
+ await page.close();
103
+ }
104
+ return figures;
105
+ }
106
+
107
+ export const escapeHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -0,0 +1,299 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import MarkdownIt from 'markdown-it';
4
+ import { exists, readText, writeText, readJson } from '../util/fsx.js';
5
+ import { TEMPLATES, PKG_ROOT } from '../util/paths.js';
6
+ import { loadI18n, t } from '../util/i18n.js';
7
+ import { loadFacts } from '../facts/index.js';
8
+ import { CLASSDEFS, renderMermaid, escapeHtml } from './mermaid.js';
9
+ import { resolveConditionals, resolveIncludes, resolveDirectives, resolveValues } from './directives.js';
10
+
11
+ export const DOCS = ['tad', 'axes'];
12
+ const VERSION = readJson(path.join(PKG_ROOT, 'package.json')).version;
13
+
14
+ export function slug(text, used) {
15
+ let s = String(text).replace(/<[^>]+>/g, '').replace(/&[a-z]+;/g, ' ').toLowerCase();
16
+ s = s.replace(/[^\p{L}\p{N}\s-]/gu, '').replace(/[\s_]+/g, '-').replace(/^-+|-+$/g, '') || 's';
17
+ let base = s, i = 2;
18
+ while (used.has(s)) s = `${base}-${i++}`;
19
+ used.add(s);
20
+ return s;
21
+ }
22
+
23
+ /** Extract Mermaid figures and number headings. `# Title {-}` is unnumbered, `{#id}` sets the anchor. */
24
+ export function preprocess(md) {
25
+ const figures = [];
26
+ md = md.replace(/```mermaid\r?\n([\s\S]*?)```[ \t]*\r?\n(?:Figure:[ \t]*([^\n]*)\n)?/g, (m, code, caption) => {
27
+ code = code.replace(/\s+$/, '');
28
+ const first = code.trimStart().split('\n', 1)[0];
29
+ if (/^(flowchart|graph)\b/.test(first)) code += '\n' + CLASSDEFS;
30
+ figures.push({ code, caption: (caption || '').trim() });
31
+ return `\n\n@@FIG${figures.length - 1}@@\n\n`;
32
+ });
33
+ const out = [], counters = [0, 0, 0], headings = [], used = new Set();
34
+ let inCode = false, letter = 0, sub = 0;
35
+ for (const line of md.split('\n')) {
36
+ if (line.startsWith('```')) inCode = !inCode;
37
+ const m = inCode ? null : /^(#{1,3}) (.*)$/.exec(line);
38
+ if (!m) { out.push(line); continue; }
39
+ const lvl = m[1].length;
40
+ let text = m[2].trim();
41
+ // Appendices are lettered in order, so that optional ones never leave gaps: {{letter}} and {{sub}}
42
+ if (text.includes('{{letter}}')) { letter++; sub = 0; text = text.replace('{{letter}}', String.fromCharCode(64 + letter)); }
43
+ if (text.includes('{{sub}}')) { sub++; text = text.replace('{{sub}}', `${String.fromCharCode(64 + Math.max(letter, 1))}.${sub}`); }
44
+ const attr = /\s*\{([^{}]*)\}\s*$/.exec(text);
45
+ let nonum = false, id = null;
46
+ if (attr && /^(\s*(-|#[\w-]+)\s*)+$/.test(attr[1])) {
47
+ nonum = /(^|\s)-(\s|$)/.test(attr[1]);
48
+ id = (/#([\w-]+)/.exec(attr[1]) || [])[1] || null;
49
+ text = text.slice(0, attr.index).trim();
50
+ }
51
+ let num = null;
52
+ if (!nonum) {
53
+ counters[lvl - 1]++;
54
+ for (let k = lvl; k < 3; k++) counters[k] = 0;
55
+ num = counters.slice(0, lvl).join('.');
56
+ }
57
+ if (id) used.add(id); else id = slug((num ? num + ' ' : '') + text, used);
58
+ headings.push({ lvl, id, num, text });
59
+ out.push(`${m[1]} ${num ? `<span class="num">${num}</span> ` : ''}${text}`);
60
+ }
61
+ return { md: out.join('\n'), figures, headings };
62
+ }
63
+
64
+ export function resolveRefs(md, headings, problems) {
65
+ const byId = new Map(headings.map((h) => [h.id, h]));
66
+ return md.replace(/\{\{(ref|title):([\w-]+)\}\}/g, (m, kind, id) => {
67
+ const h = byId.get(id);
68
+ if (!h) { problems.push({ level: 'error', code: 'unknown-ref', message: `Reference to unknown section ${m}` }); return `⟨${id}⟩`; }
69
+ const plain = h.text.replace(/`/g, '');
70
+ if (kind === 'ref') return `[${h.num ? '§ ' + h.num : plain}](#${id})`;
71
+ return `[${h.num ? h.num + ' ' : ''}${plain}](#${id})`;
72
+ });
73
+ }
74
+
75
+ function mime(p) {
76
+ const ext = path.extname(p).toLowerCase();
77
+ return { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[ext] || 'application/octet-stream';
78
+ }
79
+ const dataUri = (p) => `data:${mime(p)};base64,${fs.readFileSync(p).toString('base64')}`;
80
+
81
+ /** Text badge used when no logo file is configured. */
82
+ export function badgeLogo(name, color) {
83
+ const initials = String(name || '?').split(/[\s&.-]+/).filter(Boolean).slice(0, 3).map((w) => w[0].toUpperCase()).join('') || '?';
84
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160"><rect width="160" height="160" rx="32" fill="${color}"/><text x="80" y="96" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="${initials.length > 2 ? 48 : 60}" font-weight="700" fill="#fff">${escapeHtml(initials)}</text></svg>`;
85
+ return 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
86
+ }
87
+
88
+ /**
89
+ * Base name of a built document. The HTML keeps the same name from one version to the next
90
+ * (the review comments live in it); `{ versioned: true }` adds the version, for PDF and DOCX archives.
91
+ */
92
+ export function outputName(cfg, doc, { versioned = false } = {}) {
93
+ const short = String(cfg.client.short || cfg.client.name || 'CLIENT').replace(/[^\w-]+/g, '_');
94
+ const custom = cfg.outputs && cfg.outputs.names && cfg.outputs.names[doc];
95
+ const base = custom || `${doc === 'tad' ? 'TAD' : 'Axes'}_${short}`;
96
+ return versioned && !custom ? `${base}_v${cfg.document.version}` : base;
97
+ }
98
+
99
+ const md = new MarkdownIt({ html: true, linkify: false, typographer: false });
100
+
101
+ /**
102
+ * Build one document (tad | axes) of a workspace. Returns a report (paths, figures, problems).
103
+ * opts.render = false skips Mermaid rendering (tests, dry runs).
104
+ */
105
+ export async function buildDocument(wsCtx, doc, opts = {}) {
106
+ const { cfg } = wsCtx;
107
+ const problems = [];
108
+ const facts = loadFacts(wsCtx.ws);
109
+ if (!facts) throw new Error('facts/facts.json not found: run `tadforce facts` first.');
110
+ const { lang, dict } = loadI18n(cfg.language, wsCtx.ws);
111
+ const glossary = wsCtx.glossary() || {};
112
+ const srcDir = wsCtx.p('src', doc);
113
+ if (!exists(srcDir)) throw new Error(`No sources in ${srcDir}: run \`tadforce scaffold --doc ${doc}\`.`);
114
+ const files = fs.readdirSync(srcDir).filter((f) => f.endsWith('.md')).sort();
115
+ if (!files.length) throw new Error(`No Markdown chapters in ${srcDir}.`);
116
+
117
+ let text = files.map((f) => readText(path.join(srcDir, f))).join('\n\n');
118
+ const scope = { modules: facts.modules, counts: facts.counts, org: facts.org, cfg, facts, glossary };
119
+ const docName = doc === 'tad' ? t(dict, 'meta.docName') : t(dict, 'meta.axesName');
120
+ const ctx = { facts, cfg, dict, lang, glossary, doc, docName, chapterTitle: (k) => `{{title:ch-${k}}}` };
121
+ text = resolveConditionals(text, scope);
122
+ text = resolveIncludes(text, wsCtx.ws);
123
+ text = resolveDirectives(text, ctx, problems);
124
+ text = resolveConditionals(text, scope);
125
+ text = resolveValues(text, ctx, problems);
126
+
127
+ const pending = [...text.matchAll(/<!--\s*tad:pending\s+([\w.-]+)\s*-->/g)].map((m) => m[1]);
128
+ const pre = preprocess(text);
129
+ let body = resolveRefs(pre.md, pre.headings, problems);
130
+ body = body.replace(/<!--\s*tad:pending\s+([\w.-]+)\s*-->/g, (m, id) => `<div class="pending" data-section="${id}">✍ ${escapeHtml(t(dict, 'ui.pending'))} — <code>${id}</code></div>`);
131
+ body = body.replace(/<!--[\s\S]*?-->/g, '');
132
+
133
+ const figures = pre.figures;
134
+ if (opts.render !== false) await renderMermaid(figures, wsCtx.p('.cache', 'mermaid'));
135
+ else for (const f of figures) f.svg = `<pre class="mmd-src">${escapeHtml(f.code)}</pre>`;
136
+ for (const f of figures) if (f.error) problems.push({ level: 'error', code: 'mermaid', message: `Figure "${f.caption || f.code.slice(0, 40)}" failed: ${f.error}` });
137
+
138
+ let html = md.render(body);
139
+
140
+ // Callouts: blockquotes starting with [!INTEGRATOR] / [!NOTE] / [!RECO] / [!WARN]
141
+ html = html.replace(/<blockquote>\s*([\s\S]*?)<\/blockquote>/g, (m, inner) => {
142
+ const k = /\[!(\w+)\]\s*/.exec(inner);
143
+ if (!k) return m;
144
+ const kind = k[1].toLowerCase();
145
+ const title = { integrator: cfg.integrator.name ? t(dict, 'doc.callout.integrator', { integrator: cfg.integrator.name }) : t(dict, 'doc.callout.team'), note: t(dict, 'doc.callout.note'), reco: t(dict, 'doc.callout.reco'), warn: t(dict, 'doc.callout.warn') }[kind] || kind;
146
+ return `<aside class="callout ${kind}"><div class="ct">${escapeHtml(title)}</div>${inner.replace(k[0], '')}</aside>`;
147
+ });
148
+
149
+ // Figures (Mermaid and hand-made SVG / HTML blocks), numbered in reading order
150
+ let nfig = 0;
151
+ const figlist = [];
152
+ const label = t(dict, 'doc.figure');
153
+ html = html.replace(/<p>@@FIG(\d+)@@<\/p>|<p class="figcap">Figure — ([\s\S]*?)<\/p>/g, (m, idx, cap) => {
154
+ nfig++;
155
+ if (idx !== undefined) {
156
+ const f = figures[Number(idx)];
157
+ figlist.push([nfig, f.caption]);
158
+ const c = f.caption ? `${label} ${nfig} — ${escapeHtml(f.caption)}` : `${label} ${nfig}`;
159
+ return `<figure class="diagram" id="fig-${nfig}"><div class="fig-body">${f.svg}</div><figcaption>${c}</figcaption></figure>`;
160
+ }
161
+ figlist.push([nfig, cap.replace(/<[^>]+>/g, '')]);
162
+ return `<p class="figcap" id="fig-${nfig}">${label} ${nfig} — ${cap}</p>`;
163
+ });
164
+
165
+ // Heading ids (same order as preprocess) and anchors
166
+ let hi = 0;
167
+ html = html.replace(/<h([1-3])>([\s\S]*?)<\/h\1>/g, (m, lvl, inner) => {
168
+ const h = pre.headings[hi++];
169
+ if (!h) return m;
170
+ return `<h${lvl} id="${h.id}">${inner}<a class="anchor" href="#${h.id}">#</a></h${lvl}>`;
171
+ });
172
+ html = html.replace(/<table>/g, '<div class="tw"><table>').replace(/<\/table>/g, '</table></div>');
173
+
174
+ // Table of contents (levels 1-2) and list of figures
175
+ const toc = [];
176
+ let open = false;
177
+ for (const h of pre.headings.filter((x) => x.lvl <= 2)) {
178
+ const inner = `${h.num ? `<span class="num">${h.num}</span> ` : ''}${md.renderInline(h.text)}`;
179
+ if (h.lvl === 1) { if (open) toc.push('</ul></li>'); toc.push(`<li class="l1"><a href="#${h.id}">${inner}</a><ul>`); open = true; }
180
+ else toc.push(`<li class="l2"><a href="#${h.id}">${inner}</a></li>`);
181
+ }
182
+ if (open) toc.push('</ul></li>');
183
+ const figs = figlist.map(([n, c]) => `<li><a href="#fig-${n}">${label} ${n} — ${escapeHtml(c)}</a></li>`).join('');
184
+
185
+ // Page assembly
186
+ const brand = cfg.brand;
187
+ const cssStr = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
188
+ const css = readText(path.join(TEMPLATES, 'html', 'style.css'))
189
+ .replace('{{PRINT_FOOTER}}', cssStr(doc === 'axes' && cfg.integrator.name ? `${docName} — ${cfg.document.title} · ${cfg.integrator.name} · ${t(dict, 'doc.axesKicker')}` : `${docName} — ${cfg.document.title} · ${cfg.client.name} · ${cfg.document.classification}`))
190
+ .replace('{{PRINT_PAGE}}', cssStr(t(dict, 'doc.page')));
191
+ const brandCss = `:root{--brand:${brand.primary};--accent:${brand.accent};--accent2:${brand.accent2};--grad:linear-gradient(90deg,${brand.gradient[0]},${brand.gradient[1]});--th:${brand.tableHeader};--kpiMore:"${cssStr(t(dict, 'ui.kpiMore'))}"}@media print{:root{--brand:${brand.primary};--th:${brand.tableHeader}}}`;
192
+ const logo = (who) => {
193
+ const p = cfg[who].logo && wsCtx.p(cfg[who].logo);
194
+ return p && exists(p) ? dataUri(p) : badgeLogo(cfg[who].short || cfg[who].name, who === 'client' ? brand.primary : brand.accent2);
195
+ };
196
+ // Integrator and author are optional: without them the document only names the client.
197
+ const integrator = cfg.integrator.name || null;
198
+ const author = cfg.document.author || null;
199
+ const preparedBy = [author, integrator].filter(Boolean).join(' — ');
200
+ const sideLogo = integrator ? logo('integrator') : logo('client');
201
+ // The Improvement Axes are the integrator's internal document: its brand only (the client's when there is none).
202
+ const internal = doc === 'axes' && integrator;
203
+ const coverLogos = internal
204
+ ? `<img src="${logo('integrator')}" alt="${escapeHtml(integrator)}">`
205
+ : integrator
206
+ ? `<img src="${logo('integrator')}" alt="${escapeHtml(integrator)}"><img src="${logo('client')}" alt="${escapeHtml(cfg.client.name)}">`
207
+ : `<img src="${logo('client')}" alt="${escapeHtml(cfg.client.name)}">`;
208
+ const vendor = (f) => readText(path.join(TEMPLATES, 'vendor', f));
209
+ const coverTpl = readText(path.join(TEMPLATES, 'html', 'cover.html'));
210
+ const tokens = {
211
+ '{{VERSION}}': VERSION,
212
+ '{{LANG}}': t(dict, 'meta.htmlLang'),
213
+ '{{CSS}}': css,
214
+ '{{BRAND_CSS}}': brandCss,
215
+ '{{TITLE}}': escapeHtml(`${docName} — ${cfg.document.title} — ${cfg.client.name}`),
216
+ '{{DESCRIPTION}}': escapeHtml(`${docName} — ${cfg.document.title} — ${cfg.client.name}${integrator ? ` (${integrator})` : ''}`),
217
+ '{{COVER}}': coverTpl,
218
+ '{{COVER_KICKER}}': escapeHtml(doc === 'tad' ? t(dict, 'doc.kicker') : t(dict, 'doc.axesKicker')),
219
+ '{{COVER_NOTE}}': escapeHtml(t(dict, (doc === 'tad' ? 'doc.confidential' : 'doc.axesConfidential') + (integrator ? '' : 'Solo'), { client: cfg.client.name, integrator })),
220
+ '{{DOC_TITLE}}': escapeHtml(cfg.document.title),
221
+ '{{DOC_SUBTITLE}}': escapeHtml(cfg.document.subtitle || [cfg.client.name, ...facts.modules.clouds.filter((x) => !['events', 'shield', 'slack'].includes(x.id)).slice(0, 3).map((x) => x.name)].join(' · ')),
222
+ '{{DOC_REFERENCE}}': escapeHtml(doc === 'tad' ? cfg.document.reference : cfg.document.reference + '-AX'),
223
+ '{{DOC_VERSION}}': escapeHtml(`${cfg.document.version}${cfg.document.status ? ' — ' + cfg.document.status : ''}`),
224
+ '{{DOC_DATE}}': escapeHtml(cfg.document.date),
225
+ '{{DOC_CLASSIFICATION}}': escapeHtml(doc === 'tad' ? cfg.document.classification : t(dict, 'doc.axesKicker')),
226
+ '{{INTEGRATOR_NAME}}': escapeHtml(integrator || cfg.client.name),
227
+ '{{COVER_LOGOS}}': coverLogos,
228
+ '{{PREPARED_BY_ROW}}': preparedBy ? `<div><b>${escapeHtml(t(dict, 'doc.meta.author'))}</b><span>${escapeHtml(preparedBy)}</span></div>` : '',
229
+ '{{CLIENT_NAME}}': escapeHtml(cfg.client.name),
230
+ '{{TOC}}': toc.join(''),
231
+ '{{FIGLIST}}': figs,
232
+ '{{BODY}}': html,
233
+ '{{INTEGRATOR_LOGO}}': sideLogo,
234
+ '{{CLIENT_LOGO}}': logo('client'),
235
+ '{{SIDE_TITLE}}': escapeHtml(`${docName} — ${cfg.document.title}`),
236
+ '{{SIDE_SUB}}': escapeHtml(`${cfg.client.name} · v${cfg.document.version} · ${cfg.document.date}`),
237
+ '{{FOOTER}}': escapeHtml(`${integrator ? integrator + ' · ' : ''}${internal ? '' : cfg.client.name + ' · '}${doc === 'tad' ? cfg.document.classification : t(dict, 'doc.axesKicker')}`) + ` · <span class="gen">${escapeHtml(t(dict, 'doc.generatedBy'))}</span>`,
238
+ '{{I18N_JSON}}': JSON.stringify({ editor: dict.editor }).replace(/</g, '\\u003c'),
239
+ '{{BRAND_JSON}}': JSON.stringify(brand).replace(/</g, '\\u003c'),
240
+ '{{DOCX_LIB}}': vendor('html-docx.js'),
241
+ '{{CAPTURE_LIB}}': vendor('html2canvas.min.js'),
242
+ '{{EDITOR_JS}}': readText(path.join(TEMPLATES, 'html', 'editor.js')),
243
+ };
244
+ let tpl = readText(path.join(TEMPLATES, 'html', 'template.html'));
245
+ if (!figs) tpl = tpl.replace(/<details class="figs">[\s\S]*?<\/details>/, '');
246
+ // Two passes over the template only (cover tokens live inside {{COVER}}); inserted content is never re-scanned.
247
+ const fill = (s) => s.split(/(\{\{[A-Z0-9_]+\}\}|\{\{T:[\w.]+\}\})/).map((x) => {
248
+ if (tokens[x] !== undefined) return tokens[x];
249
+ const k = /^\{\{T:([\w.]+)\}\}$/.exec(x);
250
+ return k ? t(dict, k[1]) : x;
251
+ }).join('');
252
+ tokens['{{COVER}}'] = fill(coverTpl);
253
+ let out = fill(tpl);
254
+
255
+ // Comments saved in the browser are carried over, with their resolution.
256
+ const review = (cfg.review && cfg.review[doc]) || {};
257
+ const carried = [];
258
+ for (const rel of review.commentsFrom || []) {
259
+ const p = wsCtx.p(rel);
260
+ if (!exists(p)) { problems.push({ level: 'warn', code: 'comments-missing', message: `commentsFrom file not found: ${rel}` }); continue; }
261
+ carried.push(...readComments(readText(p)));
262
+ }
263
+ // The HTML keeps one name across versions: the comments already saved in it are carried over too.
264
+ const outDir = wsCtx.p(cfg.outputs.dir || 'out');
265
+ const htmlPath = path.join(outDir, outputName(cfg, doc) + '.html');
266
+ const prev = exists(htmlPath) ? readText(htmlPath) : null;
267
+ if (prev) {
268
+ const known = new Set(carried.map((c) => c.id));
269
+ for (const c of readComments(prev)) if (!known.has(c.id)) carried.push(c);
270
+ }
271
+ for (const c of carried) {
272
+ if (c.status === 'résolu') c.status = 'resolved';
273
+ const res = review.resolutions && review.resolutions[c.id];
274
+ if (res) { c.status = 'resolved'; c.response = res; }
275
+ if ((review.dropImages || []).includes(c.id)) c.image = null;
276
+ }
277
+ if (carried.length) {
278
+ const payload = JSON.stringify(carried, null, 1).replace(/</g, '\\u003c');
279
+ out = out.replace('<script type="application/json" id="tad-comments">[]</script>', () => `<script type="application/json" id="tad-comments">${payload}</script>`);
280
+ }
281
+
282
+ let backup = null;
283
+ // In-browser text edits cannot be merged into the Markdown sources: keep a copy so that nothing is lost.
284
+ if (prev && prev.includes('data-edited="1"')) {
285
+ backup = htmlPath.replace(/\.html$/, `_comments_${stamp()}.html`);
286
+ writeText(backup, prev);
287
+ problems.push({ level: 'warn', code: 'backup', message: `Existing file held in-browser text edits: copy kept as ${path.basename(backup)}. Report the edits into src/${doc}/*.md (comments are carried over automatically).` });
288
+ }
289
+ writeText(htmlPath, out);
290
+ return { doc, html: htmlPath, backup, sizeKB: Math.round(out.length / 1024), figures: nfig, mermaid: figures.length, problems, pending, headings: pre.headings };
291
+ }
292
+
293
+ export function readComments(html) {
294
+ const m = /<script type="application\/json" id="tad-comments">([\s\S]*?)<\/script>/.exec(html);
295
+ if (!m || !m[1].trim()) return [];
296
+ try { return JSON.parse(m[1]); } catch { return []; }
297
+ }
298
+
299
+ const stamp = () => new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
@@ -0,0 +1,59 @@
1
+ // Serve a built document on 127.0.0.1 so that « Save » in the page writes back to the very same file:
2
+ // one file per document, the review comments never split across copies.
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { spawn } from 'node:child_process';
8
+
9
+ const MAX_BYTES = 128 * 1024 * 1024;
10
+
11
+ /** Atomic write: a reader never sees a half-written document. */
12
+ export function writeAtomic(file, content) {
13
+ const tmp = `${file}.${process.pid}.tmp`;
14
+ fs.writeFileSync(tmp, content, 'utf8');
15
+ fs.renameSync(tmp, file);
16
+ }
17
+
18
+ /**
19
+ * Start the server. GET and PUT on /<token>/<file name> only; the random token keeps other pages
20
+ * and other local users from writing to the file. Resolves with { url, close }.
21
+ */
22
+ export function serveDocument(file, { port = 0 } = {}) {
23
+ const abs = path.resolve(file);
24
+ const token = crypto.randomBytes(12).toString('hex');
25
+ const route = `/${token}/${encodeURIComponent(path.basename(abs))}`;
26
+ const server = http.createServer((req, res) => {
27
+ const url = (req.url || '').split('?')[0];
28
+ if (url !== route) { res.writeHead(404).end(); return; }
29
+ if (req.method === 'GET') {
30
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
31
+ fs.createReadStream(abs).pipe(res);
32
+ return;
33
+ }
34
+ if (req.method === 'PUT') {
35
+ // Same-origin only: a page served elsewhere cannot overwrite the document.
36
+ const origin = req.headers.origin;
37
+ if (origin && origin !== `http://127.0.0.1:${server.address().port}`) { res.writeHead(403).end(); return; }
38
+ const chunks = []; let size = 0;
39
+ req.on('data', (d) => { size += d.length; if (size > MAX_BYTES) { res.writeHead(413).end(); req.destroy(); } else chunks.push(d); });
40
+ req.on('end', () => {
41
+ const body = Buffer.concat(chunks).toString('utf8');
42
+ if (!/^<!doctype html>/i.test(body)) { res.writeHead(400).end('not an HTML document'); return; }
43
+ writeAtomic(abs, body);
44
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, file: path.basename(abs) }));
45
+ });
46
+ return;
47
+ }
48
+ res.writeHead(405).end();
49
+ });
50
+ return new Promise((resolve, reject) => {
51
+ server.on('error', reject);
52
+ server.listen(port, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}${route}`, close: () => new Promise((r) => server.close(r)), server }));
53
+ });
54
+ }
55
+
56
+ export function openInBrowser(url) {
57
+ const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
58
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* the URL is printed anyway */ }
59
+ }
package/src/cli.js ADDED
@@ -0,0 +1,68 @@
1
+ import path from 'node:path';
2
+ import { parseArgs } from './util/args.js';
3
+ import { log, c, setQuiet } from './util/log.js';
4
+ import { readJson } from './util/fsx.js';
5
+ import { PKG_ROOT } from './util/paths.js';
6
+ import * as initCmd from './commands/init.js';
7
+ import * as doctorCmd from './commands/doctor.js';
8
+ import * as org from './commands/org.js';
9
+ import * as docs from './commands/docs.js';
10
+ import * as specCmd from './commands/spec.js';
11
+ import * as reviewCmd from './commands/review.js';
12
+ import * as updateCmd from './commands/update.js';
13
+
14
+ // The four commands of the run. Everything else stays available for experts and CI.
15
+ const MAIN = {
16
+ doctor: [doctorCmd.run, 'Everything before the writing, in one loop: tools, project, org login, metadata, questions'],
17
+ build: [docs.build, 'Build the document: HTML always, --pdf --docx on demand, then validate (--open to review it)'],
18
+ review: [reviewCmd.run, 'Open the document to comment it (Save writes to the same file) and list the open comments'],
19
+ update: [updateCmd.run, 'Months later: read the org again, archive the old facts, list the sections to rewrite, bump the version'],
20
+ };
21
+ const ADVANCED = {
22
+ init: [initCmd.run, 'Create the workspace and install the agent skills (doctor does it too)'],
23
+ auth: [org.auth, 'Authorize an org in the browser and save its alias (doctor does it too)'],
24
+ detect: [org.detect, 'Print org facts that answer the startup questions (JSON)'],
25
+ manifest: [org.manifest, 'Build the wildcard manifest filtered on what the org supports'],
26
+ retrieve: [org.retrieve, 'Retrieve the manifest into tadforce/.snapshot (force-app untouched)'],
27
+ facts: [docs.facts, 'Extract facts.json from the metadata (--org adds aggregated org facts)'],
28
+ prepare: [docs.prepare, 'manifest → retrieve → facts --org → scaffold, in one go (CI)'],
29
+ scaffold: [docs.scaffold, 'Copy the chapter skeletons into tadforce/src (existing files kept)'],
30
+ next: [specCmd.next, 'Next section to write, with its specification and the facts to use (--json)'],
31
+ spec: [specCmd.spec, 'Specification of one section (tadforce spec data.domains) — no id lists them all'],
32
+ validate: [docs.validate, 'Check the document: pending sections, placeholders, identifiers, glossary, secrets, anchors'],
33
+ comments: [docs.comments, 'List the review comments saved in a built HTML file'],
34
+ status: [docs.status, 'Where the workspace stands'],
35
+ 'install-skills': [async (a) => { const d = initCmd.installSkills(path.resolve(a.cwd || process.cwd()), a.agents ? String(a.agents).split(',') : null); log.ok(`Skills installed in ${d.join(', ')}`); return 0; }, 'Install or update the agent skills (--agents claude,agents,cursor…)'],
36
+ };
37
+ const COMMANDS = { ...MAIN, ...ADVANCED };
38
+
39
+ function help(all) {
40
+ const v = readJson(path.join(PKG_ROOT, 'package.json')).version;
41
+ console.log(`${c.violet('▲')} ${c.bold('TADForce')} ${v} — AI-Powered Salesforce Technical Documentation. ${c.dim('No evidence, no sentence.')}\n`);
42
+ console.log(`Usage: ${c.bold('npx tadforce <command> [options]')}\n`);
43
+ for (const [k, [, d]] of Object.entries(MAIN)) console.log(` ${c.cyan(k.padEnd(15))} ${d}`);
44
+ if (all) {
45
+ console.log('\nAdvanced (CI, experts):');
46
+ for (const [k, [, d]] of Object.entries(ADVANCED)) console.log(` ${c.cyan(k.padEnd(15))} ${d}`);
47
+ } else console.log(c.dim(`\n ${Object.keys(ADVANCED).length} advanced commands: tadforce help --all`));
48
+ console.log('\nCommon options: --cwd <dir> --org <alias> --doc tad|axes|all --json --quiet');
49
+ console.log(`Start here: ${c.bold('npx tadforce doctor')} then type ${c.bold('/tadforce')} in your AI agent.\n`);
50
+ }
51
+
52
+ export async function main(argv) {
53
+ const args = parseArgs(argv);
54
+ const cmd = args._[0];
55
+ if (args.quiet) setQuiet(true);
56
+ if (args.version || args.v) { console.log(readJson(path.join(PKG_ROOT, 'package.json')).version); return 0; }
57
+ if (!cmd || cmd === 'help' || args.help || args.h) { help(args.all); return 0; }
58
+ const entry = COMMANDS[cmd];
59
+ if (!entry) { log.fail(`Unknown command "${cmd}".`); help(); return 1; }
60
+ if (args.cwd) args.cwd = path.resolve(args.cwd);
61
+ try {
62
+ return (await entry[0](args)) || 0;
63
+ } catch (e) {
64
+ log.fail(e.message);
65
+ if (process.env.TADFORCE_DEBUG) console.error(e.stack);
66
+ return 1;
67
+ }
68
+ }