tadforce 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.fr.md +125 -0
- package/README.md +256 -2
- package/assets/logo-mark.svg +24 -0
- package/assets/logo.svg +31 -0
- package/bin/tadforce.js +4 -0
- package/commands/tadforce-review.md +8 -0
- package/commands/tadforce-update.md +6 -0
- package/commands/tadforce.md +10 -0
- package/package.json +60 -4
- package/skills/tadforce/SKILL.md +73 -0
- package/skills/tadforce/references/axes-rules.md +29 -0
- package/skills/tadforce/references/evidence-rules.md +40 -0
- package/skills/tadforce/references/exclusion-heuristics.md +40 -0
- package/skills/tadforce/references/figure-rules.md +42 -0
- package/skills/tadforce/references/markdown-conventions.md +67 -0
- package/skills/tadforce/references/modules/agentforce-ai.md +24 -0
- package/skills/tadforce/references/modules/analytics.md +19 -0
- package/skills/tadforce/references/modules/commerce.md +25 -0
- package/skills/tadforce/references/modules/data-cloud.md +23 -0
- package/skills/tadforce/references/modules/experience-cloud.md +24 -0
- package/skills/tadforce/references/modules/field-service.md +25 -0
- package/skills/tadforce/references/modules/industries.md +21 -0
- package/skills/tadforce/references/modules/integration-platforms.md +19 -0
- package/skills/tadforce/references/modules/marketing.md +24 -0
- package/skills/tadforce/references/modules/omnistudio.md +24 -0
- package/skills/tadforce/references/modules/platform-events.md +19 -0
- package/skills/tadforce/references/modules/revenue-cpq.md +25 -0
- package/skills/tadforce/references/modules/sales-cloud.md +26 -0
- package/skills/tadforce/references/modules/security-shield.md +18 -0
- package/skills/tadforce/references/modules/service-cloud.md +27 -0
- package/skills/tadforce/references/naming-external-systems.md +32 -0
- package/skills/tadforce/references/questions.md +59 -0
- package/skills/tadforce/references/salesforce-skills.md +27 -0
- package/skills/tadforce/references/workflow.md +85 -0
- package/skills/tadforce/references/writing-rules.md +47 -0
- package/skills/tadforce-review/SKILL.md +53 -0
- package/skills/tadforce-update/SKILL.md +48 -0
- package/src/build/brand.js +107 -0
- package/src/build/browser.js +70 -0
- package/src/build/directives.js +293 -0
- package/src/build/export.js +60 -0
- package/src/build/figures.js +119 -0
- package/src/build/mermaid.js +107 -0
- package/src/build/render.js +299 -0
- package/src/build/serve.js +59 -0
- package/src/cli.js +68 -0
- package/src/commands/docs.js +191 -0
- package/src/commands/doctor.js +366 -0
- package/src/commands/init.js +132 -0
- package/src/commands/org.js +151 -0
- package/src/commands/review.js +35 -0
- package/src/commands/spec.js +91 -0
- package/src/commands/update.js +153 -0
- package/src/facts/extractors/apex.js +111 -0
- package/src/facts/extractors/automation.js +85 -0
- package/src/facts/extractors/data.js +94 -0
- package/src/facts/extractors/integration.js +76 -0
- package/src/facts/extractors/misc.js +100 -0
- package/src/facts/extractors/security.js +92 -0
- package/src/facts/extractors/ui.js +120 -0
- package/src/facts/fingerprints.js +32 -0
- package/src/facts/heuristics/exclusions.js +143 -0
- package/src/facts/heuristics/modules.js +85 -0
- package/src/facts/heuristics/naming.js +31 -0
- package/src/facts/heuristics/signals.js +76 -0
- package/src/facts/index.js +197 -0
- package/src/facts/org.js +87 -0
- package/src/facts/xml.js +85 -0
- package/src/sf/sf.js +120 -0
- package/src/util/args.js +21 -0
- package/src/util/config.js +104 -0
- package/src/util/fsx.js +58 -0
- package/src/util/i18n.js +33 -0
- package/src/util/log.js +31 -0
- package/src/util/paths.js +11 -0
- package/src/validate/index.js +126 -0
- package/templates/axes/en/00-document-control.md +3 -0
- package/templates/axes/en/01-summary.md +7 -0
- package/templates/axes/en/02-method.md +9 -0
- package/templates/axes/en/03-findings.md +19 -0
- package/templates/axes/en/04-register.md +3 -0
- package/templates/axes/en/05-roadmap.md +3 -0
- package/templates/axes/en/99-appendix.md +5 -0
- package/templates/axes/fr/00-document-control.md +3 -0
- package/templates/axes/fr/01-summary.md +7 -0
- package/templates/axes/fr/02-method.md +9 -0
- package/templates/axes/fr/03-findings.md +19 -0
- package/templates/axes/fr/04-register.md +3 -0
- package/templates/axes/fr/05-roadmap.md +3 -0
- package/templates/axes/fr/99-appendix.md +5 -0
- package/templates/html/cover.html +12 -0
- package/templates/html/editor.js +502 -0
- package/templates/html/style.css +255 -0
- package/templates/html/template.html +88 -0
- package/templates/i18n/en.json +366 -0
- package/templates/i18n/fr.json +366 -0
- package/templates/manifest/package-tad.xml +543 -0
- package/templates/specs/sections.yml +494 -0
- package/templates/tad/en/00-document-control.md +7 -0
- package/templates/tad/en/01-summary.md +17 -0
- package/templates/tad/en/02-purpose.md +35 -0
- package/templates/tad/en/03-context.md +17 -0
- package/templates/tad/en/04-general.md +29 -0
- package/templates/tad/en/05-functional.md +7 -0
- package/templates/tad/en/06-data.md +71 -0
- package/templates/tad/en/07-application.md +49 -0
- package/templates/tad/en/08-ui.md +41 -0
- package/templates/tad/en/09-integrations.md +53 -0
- package/templates/tad/en/10-security.md +59 -0
- package/templates/tad/en/11-reporting.md +13 -0
- package/templates/tad/en/12-operations.md +25 -0
- package/templates/tad/en/13-nfr.md +9 -0
- package/templates/tad/en/14-decisions.md +3 -0
- package/templates/tad/en/15-outlook.md +3 -0
- package/templates/tad/en/99-appendices.md +119 -0
- package/templates/tad/fr/00-document-control.md +7 -0
- package/templates/tad/fr/01-summary.md +17 -0
- package/templates/tad/fr/02-purpose.md +35 -0
- package/templates/tad/fr/03-context.md +17 -0
- package/templates/tad/fr/04-general.md +29 -0
- package/templates/tad/fr/05-functional.md +7 -0
- package/templates/tad/fr/06-data.md +71 -0
- package/templates/tad/fr/07-application.md +49 -0
- package/templates/tad/fr/08-ui.md +41 -0
- package/templates/tad/fr/09-integrations.md +53 -0
- package/templates/tad/fr/10-security.md +59 -0
- package/templates/tad/fr/11-reporting.md +13 -0
- package/templates/tad/fr/12-operations.md +25 -0
- package/templates/tad/fr/13-nfr.md +9 -0
- package/templates/tad/fr/14-decisions.md +3 -0
- package/templates/tad/fr/15-outlook.md +3 -0
- package/templates/tad/fr/99-appendices.md +119 -0
- package/templates/vendor/LICENSE-html-docx-js.txt +21 -0
- package/templates/vendor/LICENSE-html2canvas.txt +22 -0
- package/templates/vendor/LICENSE-mermaid.txt +21 -0
- package/templates/vendor/html-docx.js +13216 -0
- package/templates/vendor/html2canvas.min.js +20 -0
- package/templates/vendor/mermaid.min.js +2314 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Client branding: find the logo in the org snapshot, derive the document colours from evidence
|
|
2
|
+
// (the Lightning theme of the org first, the logo second). Everything stays editable in tadforce.config.json.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { walk, readText } from '../util/fsx.js';
|
|
6
|
+
import { getBrowser } from './browser.js';
|
|
7
|
+
|
|
8
|
+
const IMAGE = /\.(svg|png|jpe?g|webp|gif)$/i;
|
|
9
|
+
const IMAGE_TYPES = { 'image/svg+xml': '.svg', 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
|
|
10
|
+
|
|
11
|
+
/** Logo candidates in retrieved metadata: static resources, content assets and site assets named *logo*. */
|
|
12
|
+
export function findLogoCandidates(dirs) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const dir of dirs) {
|
|
15
|
+
for (const f of walk(dir, { filter: (p) => /logo/i.test(path.basename(p)) && !/-meta\.xml$/.test(p) })) {
|
|
16
|
+
const rel = f.split(path.sep);
|
|
17
|
+
const folder = rel.find((x) => ['staticresources', 'contentassets', 'experiences', 'digitalExperiences', 'documents', 'siteDotComSites'].includes(x));
|
|
18
|
+
if (!folder) continue;
|
|
19
|
+
let ext = path.extname(f).toLowerCase();
|
|
20
|
+
// Static resources and content assets keep their MIME type in the companion -meta.xml.
|
|
21
|
+
if (!IMAGE.test(f)) {
|
|
22
|
+
const meta = [f + '-meta.xml', f.replace(/\.[^.]+$/, '') + '.resource-meta.xml', f.replace(/\.[^.]+$/, '') + '.asset-meta.xml'].find((m) => fs.existsSync(m));
|
|
23
|
+
const type = meta && (/<contentType>([^<]+)<\/contentType>/.exec(readText(meta)) || [])[1];
|
|
24
|
+
ext = type && IMAGE_TYPES[type.trim()];
|
|
25
|
+
if (!ext) {
|
|
26
|
+
const head = fs.readFileSync(f).subarray(0, 12);
|
|
27
|
+
ext = head[0] === 0x89 && head[1] === 0x50 ? '.png' : head[0] === 0xff && head[1] === 0xd8 ? '.jpg' : /^\s*<(\?xml|svg)/.test(head.toString('utf8')) ? '.svg' : null;
|
|
28
|
+
}
|
|
29
|
+
if (!ext) continue;
|
|
30
|
+
}
|
|
31
|
+
out.push({ file: f, ext, source: folder, size: fs.statSync(f).size });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// "logo" alone in the name first, then the larger file (a header logo beats a favicon).
|
|
35
|
+
const score = (c) => (/^(client|company|brand)?[-_ ]?logo/i.test(path.basename(c.file)) ? 2 : 0) + (c.source === 'staticresources' ? 1 : 0);
|
|
36
|
+
return out.sort((a, b) => score(b) - score(a) || b.size - a.size);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Brand colours declared in the org: LightningExperienceTheme → BrandingSet properties. */
|
|
40
|
+
export function orgThemeColors(dirs) {
|
|
41
|
+
const colors = [];
|
|
42
|
+
for (const dir of dirs) {
|
|
43
|
+
for (const f of walk(dir, { filter: (p) => /\.brandingSet-meta\.xml$/.test(p) })) {
|
|
44
|
+
const xml = readText(f);
|
|
45
|
+
for (const m of xml.matchAll(/<brandingSetProperty>([\s\S]*?)<\/brandingSetProperty>/g)) {
|
|
46
|
+
const name = (/<propertyName>([^<]+)</.exec(m[1]) || [])[1] || '';
|
|
47
|
+
const value = (/<propertyValue>([^<]+)</.exec(m[1]) || [])[1] || '';
|
|
48
|
+
if (/^#[0-9a-f]{6}$/i.test(value.trim()) && /brand|primary|action|header/i.test(name)) colors.push({ name, value: value.trim().toUpperCase(), file: path.basename(f) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return colors;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------- colour maths (WCAG relative luminance)
|
|
56
|
+
const hex2rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
57
|
+
const rgb2hex = (c) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
58
|
+
const lum = (h) => { const [r, g, b] = hex2rgb(h).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
|
59
|
+
export const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
|
|
60
|
+
const mix = (h, target, t) => rgb2hex(hex2rgb(h).map((v, i) => v + (hex2rgb(target)[i] - v) * t));
|
|
61
|
+
/** Darken towards black until the colour reaches the contrast ratio on white. */
|
|
62
|
+
function darkEnough(h, ratio) { let c = h; for (let t = 0.1; contrast(c, '#FFFFFF') < ratio && t <= 1; t += 0.1) c = mix(h, '#000000', t); return c; }
|
|
63
|
+
const saturation = (h) => { const [r, g, b] = hex2rgb(h); const mx = Math.max(r, g, b), mn = Math.min(r, g, b); return mx === 0 ? 0 : (mx - mn) / mx; };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A document palette from one or two brand colours: headings and table headers dark enough for
|
|
67
|
+
* text on white (WCAG AA, 4.5:1), the brand colour itself kept for accents and the title gradient.
|
|
68
|
+
*/
|
|
69
|
+
export function paletteFrom(main, second) {
|
|
70
|
+
const accent = darkEnough(main, 3);
|
|
71
|
+
const accent2 = second && second !== main ? darkEnough(second, 3) : darkEnough(mix(main, '#7B3FE4', 0.45), 3);
|
|
72
|
+
return {
|
|
73
|
+
primary: darkEnough(mix(main, '#0B1A2E', 0.55), 9),
|
|
74
|
+
accent,
|
|
75
|
+
accent2,
|
|
76
|
+
gradient: [main, second && second !== main ? second : accent2],
|
|
77
|
+
tableHeader: darkEnough(mix(main, '#0B1A2E', 0.4), 7),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Dominant saturated colours of an image, rendered in the headless browser (no extra dependency). */
|
|
82
|
+
export async function imageColors(file, max = 2) {
|
|
83
|
+
const mime = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[path.extname(file).toLowerCase()];
|
|
84
|
+
const uri = `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`;
|
|
85
|
+
const browser = await getBrowser();
|
|
86
|
+
const page = await browser.newPage();
|
|
87
|
+
try {
|
|
88
|
+
const buckets = await page.evaluate(async (src) => {
|
|
89
|
+
const img = new Image(); img.src = src; await img.decode();
|
|
90
|
+
const w = 96, h = Math.max(1, Math.round((img.naturalHeight / img.naturalWidth) * 96)) || 96;
|
|
91
|
+
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
|
|
92
|
+
const g = cv.getContext('2d'); g.drawImage(img, 0, 0, w, h);
|
|
93
|
+
const d = g.getImageData(0, 0, w, h).data, count = {};
|
|
94
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
95
|
+
if (d[i + 3] < 200) continue;
|
|
96
|
+
const k = [d[i], d[i + 1], d[i + 2]].map((v) => Math.round(v / 24) * 24).join(',');
|
|
97
|
+
count[k] = (count[k] || 0) + 1;
|
|
98
|
+
}
|
|
99
|
+
return Object.entries(count).sort((a, b) => b[1] - a[1]).slice(0, 40);
|
|
100
|
+
}, uri);
|
|
101
|
+
const colors = buckets.map(([k]) => rgb2hex(k.split(',').map(Number)))
|
|
102
|
+
.filter((c) => saturation(c) > 0.25 && lum(c) > 0.02 && lum(c) < 0.85);
|
|
103
|
+
const picked = [];
|
|
104
|
+
for (const c of colors) if (!picked.some((p) => contrast(p, c) < 1.6)) { picked.push(c); if (picked.length === max) break; }
|
|
105
|
+
return picked;
|
|
106
|
+
} finally { await page.close(); }
|
|
107
|
+
}
|
|
@@ -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
|
+
}
|