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,70 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ /** Find a Chromium-based browser already installed (Chrome, Edge, Chromium) or a Puppeteer download. */
7
+ export function findBrowser() {
8
+ const env = process.env.TADFORCE_CHROME || process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH;
9
+ if (env && fs.existsSync(env)) return env;
10
+ const pf = process.env.ProgramFiles || 'C:\\Program Files';
11
+ const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
12
+ const local = process.env.LOCALAPPDATA || '';
13
+ const candidates = process.platform === 'win32'
14
+ ? [
15
+ path.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
16
+ path.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
17
+ path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'),
18
+ path.join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
19
+ path.join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
20
+ ]
21
+ : process.platform === 'darwin'
22
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium']
23
+ : [];
24
+ for (const c of candidates) if (c && fs.existsSync(c)) return c;
25
+ if (process.platform !== 'win32') {
26
+ for (const bin of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']) {
27
+ const r = spawnSync('which', [bin], { encoding: 'utf8' });
28
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
29
+ }
30
+ }
31
+ // Browsers downloaded with `npx @puppeteer/browsers install chrome-headless-shell@stable`
32
+ for (const base of [path.join(os.homedir(), '.cache', 'puppeteer'), path.join(process.cwd(), 'chrome-headless-shell'), path.join(process.cwd(), 'chrome')]) {
33
+ if (!fs.existsSync(base)) continue;
34
+ const hit = findExecutable(base, 4);
35
+ if (hit) return hit;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function findExecutable(dir, depth) {
41
+ if (depth < 0) return null;
42
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ const full = path.join(dir, e.name);
44
+ if (e.isFile() && /^(chrome|chrome-headless-shell)(\.exe)?$/.test(e.name)) return full;
45
+ if (e.isDirectory()) { const r = findExecutable(full, depth - 1); if (r) return r; }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ let shared = null;
51
+
52
+ /** Launch (once) a headless browser. Caller closes it with closeBrowser(). */
53
+ export async function getBrowser() {
54
+ if (shared) return shared;
55
+ const executablePath = findBrowser();
56
+ if (!executablePath) {
57
+ throw new Error('No Chrome, Edge or Chromium found. Install one, or run `npx @puppeteer/browsers install chrome-headless-shell@stable` and set TADFORCE_CHROME to its path.');
58
+ }
59
+ const { default: puppeteer } = await import('puppeteer-core');
60
+ shared = await puppeteer.launch({
61
+ executablePath,
62
+ headless: true,
63
+ args: ['--disable-gpu', '--allow-file-access-from-files', '--no-first-run', '--no-default-browser-check', ...(process.platform === 'linux' ? ['--no-sandbox'] : [])],
64
+ });
65
+ return shared;
66
+ }
67
+
68
+ export async function closeBrowser() {
69
+ if (shared) { const b = shared; shared = null; await b.close(); }
70
+ }
@@ -0,0 +1,293 @@
1
+ // Deterministic content generated from facts: appendices, ERDs, figures, KPI tiles, placeholders.
2
+ import path from 'node:path';
3
+ import { t, formatNumber } from '../util/i18n.js';
4
+ import { exists, readText } from '../util/fsx.js';
5
+ import { contextView, layersBlock } from './figures.js';
6
+
7
+ const cell = (v) => String(v ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
8
+ const code = (v) => (v ? '`' + String(v).replace(/`/g, '') + '`' : '—');
9
+ const list = (xs, n = 8, dict) => (xs.length > n ? xs.slice(0, n).map(code).join(', ') + ' ' + t(dict, 'annex.more', { n: xs.length - n }) : xs.map(code).join(', ') || '—');
10
+
11
+ export function mdTable(headers, rows) {
12
+ if (!rows.length) return '';
13
+ return ['| ' + headers.map(cell).join(' | ') + ' |', '|' + headers.map(() => '---').join('|') + '|', ...rows.map((r) => '| ' + r.map(cell).join(' | ') + ' |')].join('\n');
14
+ }
15
+
16
+ /** Read a dotted path from an object: get(facts, 'counts.apex.classes'). */
17
+ export function get(obj, p) {
18
+ return p.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
19
+ }
20
+
21
+ const truthy = (v) => (Array.isArray(v) ? v.length > 0 : v && typeof v === 'object' ? Object.keys(v).length > 0 : !!v);
22
+
23
+ /** <!-- tad:if modules.omnistudio --> … <!-- tad:else --> … <!-- tad:endif --> (nestable, `!` negates). */
24
+ export function resolveConditionals(text, scope) {
25
+ const out = [];
26
+ const stack = [];
27
+ for (const line of text.split('\n')) {
28
+ const mIf = /^\s*<!--\s*tad:if\s+(!?)([\w.|]+)\s*-->\s*$/.exec(line);
29
+ const mElse = /^\s*<!--\s*tad:else\s*-->\s*$/.test(line);
30
+ const mEnd = /^\s*<!--\s*tad:endif\s*-->\s*$/.test(line);
31
+ const active = stack.every((s) => s.on);
32
+ if (mIf) {
33
+ let v = mIf[2].split('|').some((p) => truthy(get(scope, p)));
34
+ if (mIf[1]) v = !v;
35
+ stack.push({ on: v, parentOn: active });
36
+ continue;
37
+ }
38
+ if (mElse && stack.length) { const s = stack[stack.length - 1]; s.on = !s.on; continue; }
39
+ if (mEnd && stack.length) { stack.pop(); continue; }
40
+ if (active) out.push(line);
41
+ }
42
+ return out.join('\n');
43
+ }
44
+
45
+ export function resolveIncludes(text, baseDir) {
46
+ return text.replace(/^@@INCLUDE:(.+?)@@[ \t]*$/gm, (m, rel) => {
47
+ const p = path.resolve(baseDir, rel.trim());
48
+ if (!exists(p)) return `> [!WARN]\n> Missing include: \`${rel}\``;
49
+ const content = readText(p);
50
+ return /\.svg$/i.test(p) ? '<div class="fig-body hand">' + content.replace(/\n\n/g, '\n') + '</div>' : content;
51
+ });
52
+ }
53
+
54
+ /** {{n:path}} numbers (from counts, then facts), {{v:path}} raw values, {{cfg:path}} configuration. */
55
+ export function resolveValues(text, ctx, problems) {
56
+ return text.replace(/\{\{(n|v|cfg):([\w.-]+)\}\}/g, (m, kind, p) => {
57
+ const src = kind === 'cfg' ? ctx.cfg : ctx.facts;
58
+ let v = kind === 'n' ? (get(ctx.facts.counts, p) ?? get(ctx.facts, p)) : get(src, p);
59
+ if (kind === 'n' && Array.isArray(v)) v = v.length;
60
+ if (v === undefined || v === null) { problems.push({ level: 'error', code: 'unknown-placeholder', message: `Unknown placeholder ${m}` }); return `⟨${m.slice(2, -2)}⟩`; }
61
+ return kind === 'n' && typeof v === 'number' ? formatNumber(v, ctx.lang) : String(v);
62
+ });
63
+ }
64
+
65
+ // ---------------------------------------------------------------- appendices
66
+ function annexes(ctx) {
67
+ const { facts: f, dict, cfg, glossary } = ctx;
68
+ const A = (k) => t(dict, 'annex.' + k);
69
+ const yes = A('yes'), no = A('no');
70
+ const kindsLabel = (ks) => ks.filter((k) => dict.kinds[k]).map((k) => dict.kinds[k]).join(', ') || '—';
71
+ const local = f.data.objects.filter((o) => !o.namespace);
72
+ const n = (x) => formatNumber(x, ctx.lang);
73
+ return {
74
+ 'doc-control': () => mdTable([t(dict, 'control.element'), t(dict, 'control.value')], [
75
+ [t(dict, 'control.title'), `${ctx.docName || t(dict, 'meta.docName')} — ${cfg.document.title}`],
76
+ [t(dict, 'control.reference'), ctx.doc === 'axes' ? cfg.document.reference + '-AX' : cfg.document.reference],
77
+ [t(dict, 'control.client'), cfg.client.name],
78
+ ...(cfg.integrator.name ? [[t(dict, 'control.integrator'), cfg.integrator.name]] : []),
79
+ ...(cfg.document.author ? [[t(dict, 'control.preparedBy'), cfg.document.author]] : []),
80
+ [t(dict, 'control.version'), `${cfg.document.version}${cfg.document.status ? ' — ' + cfg.document.status : ''}`],
81
+ [t(dict, 'control.date'), cfg.document.date],
82
+ [t(dict, 'control.classification'), cfg.document.classification],
83
+ [t(dict, 'control.scope'), t(dict, 'control.scopeValue', { environment: cfg.document.environment, client: cfg.client.name, date: cfg.document.date })
84
+ + (cfg.scope && (cfg.scope.include || []).length ? ' — ' + t(dict, 'control.scopeLimited', { scope: cfg.scope.include.map((x) => '`' + x + '`').join(', ') }) : '')],
85
+ ]),
86
+ 'version-history': () => mdTable([t(dict, 'control.version'), t(dict, 'control.date'), t(dict, 'control.author'), t(dict, 'control.purpose')],
87
+ (cfg.document.history && cfg.document.history.length ? cfg.document.history : [{ version: cfg.document.version, date: cfg.document.date, author: [cfg.document.author, cfg.integrator.name].filter(Boolean).join(' — ') || cfg.client.name, purpose: t(dict, 'control.firstVersion', { client: cfg.client.name }) }])
88
+ .map((h) => [h.version, h.date, h.author, h.purpose])),
89
+ legend: () => `<div class="legend">\n<span class="lg actor">${t(dict, 'legend.actor')}</span>\n<span class="lg sf">${t(dict, 'legend.sf')}</span>\n<span class="lg ext">${t(dict, 'legend.ext')}</span>\n<span class="lg data">${t(dict, 'legend.data')}</span>\n</div>`,
90
+ 'key-figures': () => mdTable([A('domain'), A('count')], [
91
+ [t(dict, 'keyfig.apps'), n(f.counts.ui.apps)], [t(dict, 'keyfig.pages'), n(f.counts.ui.flexipages)], [t(dict, 'keyfig.lwc'), n(f.counts.ui.lwc)],
92
+ ...(f.counts.ui.aura ? [[t(dict, 'keyfig.aura'), n(f.counts.ui.aura)]] : []), ...(f.counts.ui.vfPages ? [[t(dict, 'keyfig.vf'), n(f.counts.ui.vfPages)]] : []),
93
+ [t(dict, 'keyfig.classes'), n(f.counts.apex.classes)], [t(dict, 'keyfig.tests'), n(f.counts.apex.tests)], [t(dict, 'keyfig.triggers'), n(f.counts.apex.triggers)],
94
+ [t(dict, 'keyfig.flows'), `${n(f.counts.flows.active)} / ${n(f.counts.flows.total)}`],
95
+ ...(f.modules.omnistudio ? [[t(dict, 'keyfig.os'), n(f.counts.omni.scriptsActive)], [t(dict, 'keyfig.ips'), n(f.counts.omni.ipsActive)], [t(dict, 'keyfig.dms'), n(f.counts.omni.dataMappers)]] : []),
96
+ [t(dict, 'keyfig.objects'), n(f.counts.objects.custom)], [t(dict, 'keyfig.fields'), n(f.counts.fields.custom)], [t(dict, 'keyfig.cmdt'), `${n(f.counts.cmdt.types)} / ${n(f.counts.cmdt.records)}`],
97
+ [t(dict, 'keyfig.psg'), `${n(f.counts.security.permissionSetGroups)} / ${n(f.counts.security.permissionSets)}`], [t(dict, 'keyfig.roles'), n(f.counts.security.roles)],
98
+ [t(dict, 'keyfig.nc'), `${n(f.counts.integration.namedCredentials)} / ${n(f.counts.integration.externalCredentials)}`], [t(dict, 'keyfig.rest'), n(f.counts.integration.restResources)],
99
+ [t(dict, 'keyfig.reports'), `${n(f.counts.misc.reports)} / ${n(f.counts.misc.dashboards)}`],
100
+ ]),
101
+ products: () => mdTable([A('product'), A('evidence')], [
102
+ ...f.modules.clouds.map((x) => [x.name, x.evidence.map((e) => (e === 'metadata' ? A('evMetadata') : e.replace(/^(namespace|object) (.+)$/, (m0, k, v) => A(k === 'namespace' ? 'evNamespace' : 'evObject') + ' ' + code(v)))).join(', ')]),
103
+ ...(f.modules.omniChannel && !f.modules.clouds.some((x) => x.id === 'serviceCloud') ? [['Omni-Channel', `${f.misc.signals.serviceChannels} service channels`]] : []),
104
+ ...(f.modules.cti ? [['CTI / call centers', f.integration.callCenters.map((x) => code(x.name)).join(', ')]] : []),
105
+ ...f.namespaces.filter((x) => !f.modules.clouds.some((cl) => cl.evidence.includes('namespace ' + x.namespace))).map((x) => [x.product || A('managed'), `${code(x.namespace)} · ${n(x.references)} references${x.installed ? ', installed package' : ''}`]),
106
+ ]),
107
+ naming: () => mdTable([A('family'), A('convention'), A('examples')], [
108
+ ...f.naming.apex.suffixes.map((s) => ['Apex', `*${s.value}`, n(s.count)]),
109
+ ...['apex', 'triggers', 'lwc', 'flows', 'objects', 'permissionSets'].flatMap((k) => f.naming[k].prefixes.map((p) => [k === 'apex' ? 'Apex' : k === 'lwc' ? 'LWC' : k[0].toUpperCase() + k.slice(1), `${p.value}*`, n(p.count)])),
110
+ ]),
111
+ objects: () => mdTable([A('apiName'), A('label'), A('owd'), A('fields'), A('recordTypes'), A('relations'), A('description')],
112
+ local.filter((o) => o.kind === 'custom').map((o) => [code(o.name), o.label || '—', `${o.sharingModel || '—'} / ${o.externalSharingModel || '—'}`, o.customFields, o.recordTypes.length, o.relations.length, o.description || '—'])),
113
+ 'standard-objects': () => mdTable([A('apiName'), A('fields'), A('recordTypes'), A('owd')],
114
+ local.filter((o) => o.kind === 'standard' && (o.customFields > 0 || o.recordTypes.length)).map((o) => [code(o.name), o.customFields, o.recordTypes.length, `${o.sharingModel || '—'} / ${o.externalSharingModel || '—'}`])),
115
+ 'external-ids': () => mdTable([A('object'), A('field')], local.flatMap((o) => o.externalIds.map((x) => [code(o.name), code(x)]))),
116
+ 'record-types': () => mdTable([A('object'), A('recordTypes')], local.filter((o) => o.recordTypes.length).map((o) => [code(o.name), o.recordTypes.filter((r) => r.active).map((r) => code(r.name)).join(', ') || '—'])),
117
+ 'validation-rules': () => mdTable([A('object'), A('rule'), A('status')], local.flatMap((o) => o.validationRules.map((v) => [code(o.name), code(v.name), v.active ? A('active') : A('inactive')]))),
118
+ cmdt: () => mdTable([A('type'), A('records')], f.data.cmdtTypes.map((x) => [code(x.type), n(x.records)])),
119
+ 'platform-events': () => mdTable([A('apiName'), A('label')], local.filter((o) => o.kind === 'platformEvent').map((o) => [code(o.name), o.label || '—'])),
120
+ volumes: () => (f.org && f.org.volumes ? mdTable([A('object'), A('records'), A('lastCreated')], f.org.volumes.filter((v) => v.records > 0).sort((a, b) => b.records - a.records).map((v) => [code(v.object), n(v.records), v.lastCreated || '—'])) : ''),
121
+ 'apex-summary': () => mdTable([A('kinds'), A('count')], Object.entries(dict.kinds).map(([k, label]) => [label, f.apex.classes.filter((c) => !c.test && c.kinds.includes(k)).length]).filter(([, c]) => c > 0).map(([l, c]) => [l, n(c)])),
122
+ triggers: () => mdTable([A('trigger'), A('object'), A('events')], f.apex.triggers.map((x) => [code(x.name), code(x.object), x.events.join(', ')])),
123
+ async: () => mdTable([A('class'), A('kinds')], f.apex.classes.filter((c) => !c.test && c.kinds.some((k) => ['batch', 'queueable', 'schedulable', 'future'].includes(k))).map((c) => [code(c.name), kindsLabel(c.kinds.filter((k) => ['batch', 'queueable', 'schedulable', 'future', 'stateful', 'callout'].includes(k)))])),
124
+ rest: () => mdTable([A('url'), A('class'), A('kinds')], f.apex.classes.filter((c) => c.restUrl || c.kinds.includes('soap')).map((c) => [code(c.restUrl || 'SOAP'), code(c.name), c.restUrl ? 'Apex REST' : 'Apex SOAP'])),
125
+ flows: () => mdTable([A('apiName'), A('label'), A('processType'), A('start'), A('object'), A('status')], f.automation.flows.map((x) => [code(x.name), x.label || '—', x.processType || '—', [x.triggerType, x.recordTriggerType, x.scheduleFrequency].filter(Boolean).join(' · ') || '—', x.object ? code(x.object) : '—', x.status])),
126
+ 'omni-scripts': () => mdTable([A('apiName'), A('label'), A('type'), A('language'), A('status')], f.automation.omnistudio.omniScripts.filter((x) => x.active).map((x) => [code(x.name), x.label || '—', `${x.type || ''}/${x.subType || ''}`, x.language || '—', A('active')])),
127
+ 'integration-procedures': () => mdTable([A('apiName'), A('label'), A('type'), A('status')], f.automation.omnistudio.integrationProcedures.filter((x) => x.active).map((x) => [code(x.name), x.label || '—', `${x.type || ''}/${x.subType || ''}`, A('active')])),
128
+ 'data-mappers': () => mdTable([A('apiName'), A('type')], f.automation.omnistudio.dataMappers.map((x) => [code(x.name), x.type || '—'])),
129
+ apps: () => mdTable([A('apiName'), A('label'), A('navigation'), A('formFactors')], f.ui.apps.map((a) => [code(a.name), a.label || '—', a.navType || '—', a.formFactors.join(', ') || '—'])),
130
+ flexipages: () => mdTable([A('apiName'), A('type'), A('object'), A('components')], f.ui.flexipages.map((p) => [code(p.name), p.type || '—', p.object ? code(p.object) : '—', list(p.customComponents, 6, dict)])),
131
+ lwc: () => mdTable([A('apiName'), A('placement'), A('controllers')], f.ui.lwc.map((l) => [code(l.name), l.placedIn.length ? [...new Set(l.placedIn.map((u) => u.name))].slice(0, 4).map(code).join(', ') + (l.placedIn.length > 4 ? ' …' : '') : '—', list(l.apex, 4, dict)])),
132
+ aura: () => mdTable([A('apiName'), A('type'), A('placement')], f.ui.aura.map((a) => [code(a.name), a.type, a.placedIn.length ? a.placedIn.slice(0, 4).map((u) => code(u.name)).join(', ') : '—'])),
133
+ visualforce: () => mdTable([A('apiName'), A('controllers')], f.ui.vfPages.map((p) => [code(p.name), [p.controller, ...p.extensions].filter(Boolean).map(code).join(', ') || '—'])),
134
+ psg: () => mdTable([A('apiName'), A('label'), A('permissionSets')].concat(f.org && f.org.psgAssignments ? [A('users')] : []), f.security.permissionSetGroups.map((g) => {
135
+ const row = [code(g.name), g.label || '—', list(g.permissionSets, 10, dict)];
136
+ if (f.org && f.org.psgAssignments) row.push(n((f.org.psgAssignments.find((a) => a.group === g.name) || {}).users || 0));
137
+ return row;
138
+ })),
139
+ 'permission-sets': () => mdTable([A('apiName'), A('label'), A('license')], f.security.permissionSets.map((p) => [code(p.name), p.label || '—', p.license || '—'])),
140
+ profiles: () => mdTable([A('name'), A('license')].concat(f.org && f.org.usersByProfile ? [A('users')] : []), f.security.profiles.map((p) => {
141
+ const row = [code(p.name), p.userLicense || '—'];
142
+ if (f.org && f.org.usersByProfile) row.push(n((f.org.usersByProfile.find((u) => u.profile === p.name) || {}).users || 0));
143
+ return row;
144
+ })),
145
+ roles: () => mdTable([A('apiName'), A('label'), A('parent')], f.security.roles.map((r) => [code(r.name), r.label || '—', r.parent ? code(r.parent) : '—'])),
146
+ owd: () => mdTable([A('object'), A('owd')], local.filter((o) => o.sharingModel && ['custom', 'standard'].includes(o.kind)).map((o) => [code(o.name), `${o.sharingModel} / ${o.externalSharingModel || '—'}`])),
147
+ 'sharing-rules': () => mdTable([A('object'), A('rule'), A('kind'), A('access')], f.security.sharingRules.flatMap((s) => s.rules.map((r) => [code(s.object), code(r.name), r.kind, r.access || '—']))),
148
+ queues: () => mdTable([A('apiName'), A('label'), A('object')], f.security.queues.map((q) => [code(q.name), q.label || '—', q.objects.map(code).join(', ') || '—'])),
149
+ 'named-credentials': () => mdTable([A('apiName'), A('label'), A('type'), A('endpoint'), A('externalCredential')], f.integration.namedCredentials.map((x) => [code(x.name), x.label || '—', x.type, x.host || '—', x.externalCredential ? code(x.externalCredential) : '—'])),
150
+ 'external-credentials': () => mdTable([A('apiName'), A('label'), A('protocol')], f.integration.externalCredentials.map((x) => [code(x.name), x.label || '—', x.protocol || '—'])),
151
+ 'connected-apps': () => mdTable([A('apiName'), A('label'), A('scopes')], f.integration.connectedApps.map((x) => [code(x.name), x.label || '—', x.scopes.join(', ') || (x.saml ? 'SAML' : '—')])),
152
+ 'auth-providers': () => mdTable([A('apiName'), A('label'), A('type'), A('handler')], f.integration.authProviders.map((x) => [code(x.name), x.label || '—', x.type || '—', x.registrationHandler ? code(x.registrationHandler) : '—'])),
153
+ 'allowed-domains': () => mdTable([A('kind'), A('apiName'), A('endpoint')], [
154
+ ...f.integration.remoteSites.map((x) => ['Remote Site', code(x.name), x.host || '—']),
155
+ ...f.integration.cspTrustedSites.map((x) => ['CSP Trusted Site', code(x.name), x.host || '—']),
156
+ ...f.integration.corsOrigins.map((x) => ['CORS', code(x.name), x.host || '—']),
157
+ ]),
158
+ 'call-centers': () => mdTable([A('apiName'), A('label'), A('endpoint')], f.integration.callCenters.map((x) => [code(x.name), x.label || '—', x.adapterHost || '—'])),
159
+ systems: () => mdTable([A('system'), A('description'), A('endpoints')], f.systems.named.map((s) => [s.name, s.description || '—', s.endpoints.map((e) => `${code(e.name)}`).join(', ') || '—'])),
160
+ reports: () => mdTable([A('folder'), A('count')], f.misc.reports.folders.map((fo) => [code(fo), n((f.misc.reports.byFolder || {})[fo] || 0)])),
161
+ labels: () => mdTable([A('apiName'), A('kind')], f.misc.labels.slice(0, 40).map((l) => [code(l.name), l.category || '—'])),
162
+ 'scheduled-jobs': () => (f.org && f.org.scheduledJobs ? mdTable([A('name'), A('type'), A('cron'), A('status')], f.org.scheduledJobs.map((j) => [j.name, j.type || '—', code(j.cron), j.state])) : ''),
163
+ packages: () => (f.org && f.org.packages && f.org.packages.length
164
+ ? mdTable([A('product'), A('namespace'), A('version')], f.org.packages.map((p) => [p.name, p.namespace ? code(p.namespace) : '—', p.version || '—']))
165
+ : mdTable([A('namespace'), A('product')], f.namespaces.map((x) => [code(x.namespace), x.product || A('managed')]))),
166
+ exclusions: () => mdTable([A('type'), A('apiName'), A('reason')], f.exclusions.applied.map((x) => [x.type, code(x.name), x.reason || '—'])),
167
+ inventory: () => mdTable([A('type'), A('count')], Object.entries(f.misc.folders).filter(([, c]) => c > 0).map(([k, c]) => [code(k), n(c)])),
168
+ traceability: () => mdTable([A('chapter'), A('metadata')], Object.entries(dict.traceability).map(([k, v]) => [ctx.chapterTitle(k), v])),
169
+ glossary: () => mdTable([A('term'), A('definition')], [
170
+ ...(glossary.terms || []).filter((x) => x.definition).map((x) => [x.term || x.use, x.definition]),
171
+ ...Object.entries(dict.glossary).map(([k, v]) => [k, v]),
172
+ ].sort((a, b) => a[0].localeCompare(b[0]))),
173
+ signals: () => mdTable([A('signal'), A('pillar'), A('priority'), A('observed'), A('evidence')], f.signals.map((s) => [t(dict, 'signals.' + s.id), t(dict, 'pillars.' + s.pillar), `<span class="b ${s.priority}">${s.priority.toUpperCase()}</span>`, String(s.value), s.evidence.length ? list(s.evidence.map((e) => e.split(' ')[0]), 6, dict) : '—'])),
174
+ 'signals-trusted': () => signalTable(ctx, 'trusted'),
175
+ 'signals-easy': () => signalTable(ctx, 'easy'),
176
+ 'signals-adaptable': () => signalTable(ctx, 'adaptable'),
177
+ 'exclusion-candidates': () => mdTable([A('type'), A('apiName'), A('reason')], f.exclusions.candidates.map((c) => [c.type, code(c.name), c.reasons.join('; ')])),
178
+ };
179
+ }
180
+
181
+ function signalTable(ctx, pillar) {
182
+ const { facts: f, dict } = ctx;
183
+ const A = (k) => t(dict, 'annex.' + k);
184
+ return mdTable([A('signal'), A('priority'), A('observed'), A('evidence')], f.signals.filter((s) => s.pillar === pillar).map((s) => [t(dict, 'signals.' + s.id), `<span class="b ${s.priority}">${s.priority.toUpperCase()}</span>`, String(s.value), s.evidence.length ? s.evidence.slice(0, 6).map((e) => '`' + e + '`').join(', ') + (s.total > 6 ? ' ' + t(dict, 'annex.more', { n: s.total - 6 }) : '') : '—']));
185
+ }
186
+
187
+ // ---------------------------------------------------------------- ERD, figures, KPIs
188
+
189
+ /** Mermaid ER diagram limited to Lookup / Master-Detail relationships between the given objects. */
190
+ export function erd(facts, objectNames, caption) {
191
+ const byName = new Map(facts.data.objects.map((o) => [o.name, o]));
192
+ let names = objectNames;
193
+ if (names.length === 1 && names[0] === 'auto') {
194
+ const degree = {};
195
+ for (const o of facts.data.objects.filter((x) => !x.namespace)) {
196
+ for (const r of o.relations) for (const to of r.to) { if (to === o.name || to === 'User' || to === 'Group') continue; degree[o.name] = (degree[o.name] || 0) + 1; degree[to] = (degree[to] || 0) + 1; }
197
+ }
198
+ names = Object.entries(degree).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 12).map(([k]) => k);
199
+ }
200
+ const set = new Set(names);
201
+ const lines = ['erDiagram'];
202
+ const seen = new Set();
203
+ for (const nm of names) {
204
+ const o = byName.get(nm);
205
+ if (!o) continue;
206
+ for (const r of o.relations) {
207
+ for (const to of r.to) {
208
+ if (!set.has(to) || to === nm) continue;
209
+ const key = `${to}>${nm}>${r.field}`;
210
+ if (seen.has(key)) continue;
211
+ seen.add(key);
212
+ const card = r.type === 'MasterDetail' ? '||--|{' : '|o--o{';
213
+ lines.push(` ${ent(to)} ${card} ${ent(nm)} : "${r.field}"`);
214
+ }
215
+ }
216
+ }
217
+ for (const nm of names) if (!lines.some((l) => l.includes(` ${ent(nm)} `) || l.endsWith(` ${ent(nm)}`))) lines.push(` ${ent(nm)} {\n Id id\n }`);
218
+ return '```mermaid\n' + lines.join('\n') + '\n```\nFigure: ' + (caption || 'Entity-relationship model') + '\n';
219
+ }
220
+ const ent = (n) => n.replace(/[^A-Za-z0-9_]/g, '_');
221
+
222
+ function rolesFigure(facts, caption) {
223
+ const roles = facts.security.roles;
224
+ if (!roles.length) return '';
225
+ const id = new Map(roles.map((r, i) => [r.name, 'r' + i]));
226
+ const depth = (r, d = 0) => (d > 12 || !r.parent || !id.has(r.parent) ? d : depth(roles.find((x) => x.name === r.parent), d + 1));
227
+ const shown = roles.filter((r) => depth(r) <= (roles.length > 40 ? 2 : 5));
228
+ const lines = ['flowchart TB'];
229
+ for (const r of shown) lines.push(` ${id.get(r.name)}["${(r.label || r.name).replace(/"/g, "'")}"]:::${r.parent ? 'sf' : 'data'}`);
230
+ for (const r of shown) if (r.parent && id.has(r.parent) && shown.some((s) => s.name === r.parent)) lines.push(` ${id.get(r.parent)} --> ${id.get(r.name)}`);
231
+ return '```mermaid\n' + lines.join('\n') + '\n```\nFigure: ' + caption + '\n';
232
+ }
233
+
234
+ function integrationsFigure(facts, cfg, dict, caption) {
235
+ const lines = ['flowchart LR', ` SF["${(t(dict, 'context.salesforce') + ' — ' + (cfg.client.short || cfg.client.name)).replace(/"/g, "'")}"]:::sf`];
236
+ let i = 0;
237
+ for (const s of facts.systems.named) {
238
+ const k = 's' + i++;
239
+ lines.push(` ${k}["${s.name.replace(/"/g, "'")}"]:::ext`);
240
+ const ncs = s.endpoints.filter((e) => e.kind === 'namedCredential').map((e) => e.name);
241
+ const label = ncs.length ? ncs.slice(0, 2).join(', ') : [...new Set(s.endpoints.map((e) => e.kind))].join(', ');
242
+ const inbound = s.endpoints.every((e) => e.kind === 'connectedApp' || e.kind === 'authProvider');
243
+ lines.push(inbound ? ` ${k} -- "${label}" --> SF` : ` SF -- "${label}" --> ${k}`);
244
+ }
245
+ const hosts = [...new Set(facts.systems.unnamed.filter((e) => e.host).map((e) => e.host))].slice(0, 8);
246
+ for (const h of hosts) { const k = 's' + i++; lines.push(` ${k}["${h}"]:::ext`); lines.push(` SF --> ${k}`); }
247
+ if (facts.counts.integration.restResources) lines.push(` IN["${t(dict, 'context.callers')}"]:::actor`, ` IN -- "Apex REST" --> SF`);
248
+ if (i === 0 && !facts.counts.integration.restResources) return '';
249
+ return '```mermaid\n' + lines.join('\n') + '\n```\nFigure: ' + caption + '\n';
250
+ }
251
+
252
+ function kpis(ctx) {
253
+ const { facts: f, dict } = ctx;
254
+ const n = (x) => formatNumber(x, ctx.lang);
255
+ const tile = (href, value, label) => `<a class="kpi" href="#${href}"><b>${value}</b><span>${label}</span></a>`;
256
+ const tiles = [
257
+ tile('annex-apps', n(f.counts.ui.apps), t(dict, 'kpi.apps')),
258
+ tile('annex-objects', n(f.counts.objects.custom), t(dict, 'kpi.objects')),
259
+ tile('annex-apex', n(f.counts.apex.classes), t(dict, 'kpi.classes', { tests: n(f.counts.apex.tests) })),
260
+ tile('annex-lwc', n(f.counts.ui.lwc), t(dict, 'kpi.lwc')),
261
+ f.modules.omnistudio && tile('annex-omnistudio', `${n(f.counts.omni.scriptsActive)} / ${n(f.counts.omni.ipsActive)}`, t(dict, 'kpi.omni')),
262
+ tile('annex-automation', n(f.counts.flows.active), t(dict, 'kpi.flows')),
263
+ f.counts.cmdt.records && tile('annex-cmdt', n(f.counts.cmdt.records), t(dict, 'kpi.cmdt', { types: n(f.counts.cmdt.types) })),
264
+ f.counts.integration.systems ? tile('annex-integration', n(f.counts.integration.systems), t(dict, 'kpi.systems')) : tile('annex-integration', n(f.counts.integration.namedCredentials + f.counts.integration.restResources), t(dict, 'kpi.integrations')),
265
+ ].filter(Boolean);
266
+ return `<div class="kpis">\n${tiles.join('\n')}\n</div>`;
267
+ }
268
+
269
+ /** Replace every @@DIRECTIVE@@ line by generated content. Unknown directives are reported. */
270
+ export function resolveDirectives(text, ctx, problems) {
271
+ const A = annexes(ctx);
272
+ return text.replace(/^@@([A-Z]+)(?::([^@|]*))?(?:\|([^@]*))?@@[ \t]*$/gm, (m, kind, arg = '', caption = '') => {
273
+ arg = arg.trim(); caption = caption.trim();
274
+ if (kind === 'ANNEX') {
275
+ const fn = A[arg];
276
+ if (!fn) { problems.push({ level: 'error', code: 'unknown-annex', message: `Unknown appendix generator @@ANNEX:${arg}@@` }); return ''; }
277
+ const out = fn();
278
+ return out || `<p class="empty-annex">${t(ctx.dict, 'annex.none')}</p>`;
279
+ }
280
+ if (kind === 'ERD') return erd(ctx.facts, arg.split(',').map((s) => s.trim()).filter(Boolean), caption);
281
+ if (kind === 'KPIS') return kpis(ctx);
282
+ if (kind === 'FIGURE') {
283
+ if (arg === 'context') return '<div class="fig-body hand">' + contextView(ctx.facts, ctx.glossary, ctx.cfg, ctx.dict) + '</div>\n<p class="figcap">Figure — ' + (caption || t(ctx.dict, 'context.caption')) + '</p>';
284
+ if (arg === 'layers') return layersBlock(ctx.facts, ctx.dict);
285
+ if (arg === 'roles') return rolesFigure(ctx.facts, caption || 'Role hierarchy');
286
+ if (arg === 'integrations') return integrationsFigure(ctx.facts, ctx.cfg, ctx.dict, caption || 'Integration map');
287
+ problems.push({ level: 'error', code: 'unknown-figure', message: `Unknown figure @@FIGURE:${arg}@@` });
288
+ return '';
289
+ }
290
+ problems.push({ level: 'error', code: 'unknown-directive', message: `Unknown directive ${m}` });
291
+ return '';
292
+ });
293
+ }
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { getBrowser } from './browser.js';
4
+
5
+ /** PDF through the browser print engine (same layout as « Print / export › PDF »). */
6
+ export async function exportPdf(htmlPath, pdfPath) {
7
+ const browser = await getBrowser();
8
+ const page = await browser.newPage();
9
+ try {
10
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'load', timeout: 120000 });
11
+ await page.emulateMediaType('print');
12
+ await page.pdf({ path: pdfPath, printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false, timeout: 300000 });
13
+ } finally {
14
+ await page.close();
15
+ }
16
+ return fs.statSync(pdfPath).size;
17
+ }
18
+
19
+ /** DOCX through the page's own export (same result as « Print / export › Word »). */
20
+ export async function exportDocx(htmlPath, docxPath) {
21
+ const browser = await getBrowser();
22
+ const page = await browser.newPage();
23
+ try {
24
+ await page.setViewport({ width: 1400, height: 1000 });
25
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'load', timeout: 120000 });
26
+ const b64 = await page.evaluate(async () => {
27
+ let blob = null;
28
+ const orig = URL.createObjectURL.bind(URL);
29
+ URL.createObjectURL = function (b) { blob = b; return orig(b); };
30
+ HTMLAnchorElement.prototype.click = function () {};
31
+ document.getElementById('doDocx').click();
32
+ for (let i = 0; i < 480 && !blob; i++) await new Promise((r) => setTimeout(r, 250));
33
+ if (!blob) return null;
34
+ const bytes = new Uint8Array(await blob.arrayBuffer());
35
+ let s = '';
36
+ for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
37
+ return btoa(s);
38
+ });
39
+ if (!b64) throw new Error('The page did not produce a DOCX file.');
40
+ fs.writeFileSync(docxPath, Buffer.from(b64, 'base64'));
41
+ } finally {
42
+ await page.close();
43
+ }
44
+ return fs.statSync(docxPath).size;
45
+ }
46
+
47
+ /** PNG screenshot of a page region (used for README visuals). */
48
+ export async function screenshot(htmlPath, pngPath, { width = 1440, height = 900, script } = {}) {
49
+ const browser = await getBrowser();
50
+ const page = await browser.newPage();
51
+ try {
52
+ await page.setViewport({ width, height, deviceScaleFactor: 1 });
53
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'load' });
54
+ if (script) await page.evaluate(script);
55
+ await new Promise((r) => setTimeout(r, 600));
56
+ await page.screenshot({ path: pngPath });
57
+ } finally {
58
+ await page.close();
59
+ }
60
+ }
@@ -0,0 +1,119 @@
1
+ // Data-driven figures drawn as SVG (context view) or HTML blocks (logical layers).
2
+ import { t } from '../util/i18n.js';
3
+
4
+ const STY = {
5
+ actor: ['#F1E8FF', '#7B3FE4', '#2A1458'],
6
+ sf: ['#E8F1FF', '#2F6FE4', '#0B2545'],
7
+ ext: ['#FFF2E2', '#E0861F', '#4A2A05'],
8
+ data: ['#E7F6EC', '#2E9E5B', '#0E3B22'],
9
+ warn: ['#FDECEF', '#D6336C', '#5A0A23'],
10
+ };
11
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
12
+ const clip = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
13
+
14
+ export class Svg {
15
+ constructor(w, h, title) { this.w = w; this.h = h; this.title = title; this.parts = []; }
16
+ box(x, y, w, h, kind, lines, { pill = false } = {}) {
17
+ const [fill, stroke, ink] = STY[kind];
18
+ this.parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${pill ? h / 2 : 8}" fill="${fill}" stroke="${stroke}" stroke-width="1.4"/>`);
19
+ lines.forEach((tx, i) => {
20
+ const ty = y + h / 2 + (i - (lines.length - 1) / 2) * 16 + 5;
21
+ this.parts.push(`<text x="${x + w / 2}" y="${ty}" text-anchor="middle" font-size="${i === 0 ? 13.5 : 12}" font-weight="${i === 0 ? 600 : 400}" fill="${ink}">${esc(tx)}</text>`);
22
+ });
23
+ }
24
+ group(x, y, w, h, title) {
25
+ this.parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="14" fill="#F7F9FC" stroke="#C9D4E3" stroke-width="1.4" stroke-dasharray="5 4"/>`);
26
+ this.parts.push(`<text x="${x + 14}" y="${y + 20}" font-size="13" font-weight="700" fill="#33475B">${esc(title)}</text>`);
27
+ }
28
+ arrow(pts, { label, dashed = false, both = false } = {}) {
29
+ const d = 'M' + pts.map(([a, b]) => `${a},${b}`).join(' L');
30
+ this.parts.push(`<path d="${d}" fill="none" stroke="#5B6B7D" stroke-width="1.5"${dashed ? ' stroke-dasharray="5 4"' : ''} marker-end="url(#ah)"${both ? ' marker-start="url(#ahs)"' : ''}/>`);
31
+ if (label) {
32
+ const [x1, y1] = pts[0], [x2, y2] = pts[pts.length - 1];
33
+ const lx = (x1 + x2) / 2, ly = (y1 + y2) / 2 - 6;
34
+ this.parts.push(`<rect x="${lx - label.length * 3.4 - 4}" y="${ly - 12}" width="${label.length * 6.8 + 8}" height="16" rx="4" fill="#fff"/>`);
35
+ this.parts.push(`<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11.5" fill="#33475B">${esc(label)}</text>`);
36
+ }
37
+ }
38
+ render() {
39
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${this.w} ${this.h}" role="img" aria-label="${esc(this.title)}" style="font-family:Segoe UI,Arial,sans-serif;max-width:100%">` +
40
+ `<defs><marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#5B6B7D"/></marker>` +
41
+ `<marker id="ahs" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M10,0 L0,5 L10,10 z" fill="#5B6B7D"/></marker></defs>` +
42
+ this.parts.join('') + '</svg>';
43
+ }
44
+ }
45
+
46
+ const KIND_LABEL = { namedCredential: 'Named Credential', remoteSite: 'Remote Site', cspTrustedSite: 'CSP', authProvider: 'SSO', connectedApp: 'Connected App', callCenter: 'CTI' };
47
+
48
+ /** Context view: users on the left, the Salesforce org in the middle, connected systems on the right. */
49
+ export function contextView(facts, glossary, cfg, dict) {
50
+ const actors = (glossary.actors || []).map((a) => [a.name, a.channel || a.description || '']).slice(0, 6);
51
+ if (!actors.length) {
52
+ for (const a of facts.ui.apps.slice(0, 5)) actors.push([a.label || a.name, a.console ? 'Console' : 'Lightning']);
53
+ if (!actors.length) actors.push([t(dict, 'context.users'), 'Lightning']);
54
+ }
55
+ const systems = facts.systems.named.map((s) => [s.name, [...new Set(s.endpoints.map((e) => KIND_LABEL[e.kind] || e.kind))].join(' · ')]);
56
+ const seenHosts = new Set();
57
+ for (const e of facts.systems.unnamed) {
58
+ const key = e.host || e.name;
59
+ if (seenHosts.has(key) || systems.length >= 8) continue;
60
+ seenHosts.add(key);
61
+ systems.push([e.host || e.name, KIND_LABEL[e.kind] || e.kind]);
62
+ }
63
+ const rest = facts.counts.integration.restResources;
64
+ if (rest) systems.push([t(dict, 'context.callers'), `${t(dict, 'context.rest')} · ${rest} API`]);
65
+
66
+ const m = facts.modules;
67
+ const caps = m.clouds.map((x) => x.name.replace(/ \(.*\)$/, ''));
68
+ for (const p of m.products) if (!caps.some((x) => p.startsWith(x))) caps.push(p);
69
+ if (m.omniChannel && !caps.includes('Omni-Channel')) caps.push('Omni-Channel');
70
+ if (m.cti) caps.push('CTI');
71
+ if (!caps.length) caps.push('Lightning Platform');
72
+
73
+ const rows = Math.max(actors.length, systems.length, 4);
74
+ const H = Math.max(360, 70 + rows * 64);
75
+ const svg = new Svg(1180, H, t(dict, 'context.caption'));
76
+ // actors
77
+ const ay = (i, n) => 50 + i * ((H - 80) / n);
78
+ actors.forEach(([name, sub], i) => svg.box(20, ay(i, actors.length) + 6, 230, 50, 'actor', [clip(name, 30), clip(sub, 34)]));
79
+ // salesforce org
80
+ const gx = 320, gw = 520;
81
+ svg.group(gx, 30, gw, H - 50, `${t(dict, 'context.salesforce')} — ${cfg.client.short || cfg.client.name}`);
82
+ const apps = facts.ui.apps.filter((a) => a.label).slice(0, 4);
83
+ const appW = apps.length ? (gw - 40 - (apps.length - 1) * 10) / apps.length : 0;
84
+ apps.forEach((a, i) => svg.box(gx + 20 + i * (appW + 10), 56, appW, 46, 'sf', [clip(a.label, Math.floor(appW / 8)), a.console ? 'Console' : 'Lightning']));
85
+ const capY = apps.length ? 122 : 60;
86
+ svg.parts.push(`<text x="${gx + 20}" y="${capY}" font-size="12" font-weight="700" fill="#33475B">${esc(t(dict, 'context.platform'))}</text>`);
87
+ const capList = caps.slice(0, 8);
88
+ const capW = (gw - 40 - 10) / 2;
89
+ capList.forEach((c, i) => svg.box(gx + 20 + (i % 2) * (capW + 10), capY + 10 + Math.floor(i / 2) * 40, capW, 32, 'sf', [clip(c, Math.floor(capW / 7.4))], { pill: true }));
90
+ const dataY = capY + 20 + Math.ceil(capList.length / 2) * 40;
91
+ svg.parts.push(`<text x="${gx + 20}" y="${dataY}" font-size="12" font-weight="700" fill="#33475B">${esc(t(dict, 'context.data'))}</text>`);
92
+ svg.box(gx + 20, dataY + 10, capW, 40, 'data', [t(dict, 'context.objects', { n: facts.counts.objects.custom })]);
93
+ svg.box(gx + 30 + capW, dataY + 10, capW, 40, 'data', [t(dict, 'context.cmdt', { n: facts.counts.cmdt.types })]);
94
+ // external systems
95
+ const sy = (i, n) => 50 + i * ((H - 80) / Math.max(n, 1));
96
+ systems.forEach(([name, sub], i) => svg.box(930, sy(i, systems.length) + 6, 230, 50, 'ext', [clip(name, 30), clip(sub, 34)]));
97
+ // arrows
98
+ actors.forEach((_, i) => svg.arrow([[250, ay(i, actors.length) + 31], [gx, ay(i, actors.length) + 31]]));
99
+ systems.forEach((s, i) => {
100
+ const y = sy(i, systems.length) + 31;
101
+ const inbound = s[0] === t(dict, 'context.callers');
102
+ svg.arrow(inbound ? [[930, y], [gx + gw, y]] : [[gx + gw, y], [930, y]], { dashed: inbound });
103
+ });
104
+ return svg.render();
105
+ }
106
+
107
+ /** Logical layers block (HTML), with counts from facts. */
108
+ export function layersBlock(facts, dict) {
109
+ const c = facts.counts;
110
+ const L = (k, n) => t(dict, `layers.${k}`, { n });
111
+ const span = (s) => `<span>${esc(s)}</span>`;
112
+ const row = (cls, title, items) => `<div class="layer${cls ? ' ' + cls : ''}"><div class="ln">${esc(title)}</div><div class="li">${items.filter(Boolean).map(span).join('')}</div></div>`;
113
+ const pres = [c.ui.apps && L('apps', c.ui.apps), c.ui.flexipages && L('pages', c.ui.flexipages), c.ui.lwc && L('lwc', c.ui.lwc) + (c.ui.aura ? ' · ' + L('aura', c.ui.aura) : ''), c.omni.scriptsActive && L('os', c.omni.scriptsActive), c.ui.vfPages && L('vf', c.ui.vfPages)];
114
+ const orch = [c.flows.active && L('flows', c.flows.active), c.omni.ipsActive && L('ips', c.omni.ipsActive), c.omni.dataMappers && L('dms', c.omni.dataMappers)];
115
+ const svc = [c.apex.triggers && L('triggers', c.apex.triggers), c.apex.auraEnabled && L('controllers', c.apex.auraEnabled), L('services', c.apex.classes), (c.apex.batch + c.apex.queueable + c.apex.schedulable + c.apex.future) && L('async')];
116
+ const data = [L('standard'), c.objects.custom && L('custom', c.objects.custom), c.cmdt.types && L('cmdt', c.cmdt.types), L('files')];
117
+ const integ = [c.integration.namedCredentials && L('nc', c.integration.namedCredentials), c.integration.externalCredentials && L('ec', c.integration.externalCredentials), c.integration.restResources && L('rest', c.integration.restResources), c.integration.connectedApps && L('apps2', c.integration.connectedApps)];
118
+ return `<div class="layers">\n${row('', L('presentation'), pres)}\n${row('', L('orchestration'), orch.length && orch.some(Boolean) ? orch : ['—'])}\n${row('', L('services'), svc)}\n${row('data', L('data'), data)}\n${row('int', L('integration'), integ.some(Boolean) ? integ : ['—'])}\n</div>\n<p class="figcap">Figure — ${esc(L('caption'))}</p>`;
119
+ }