@dszp/netsapiens-lib 0.1.9 → 0.3.1
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/README.md +95 -1
- package/dist/eligibility.d.ts.map +1 -0
- package/dist/eligibility.js.map +1 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +153 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +234 -0
- package/dist/inventory.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js.map +1 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/mermaid.js.map +1 -0
- package/dist/model.d.ts +18 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js.map +1 -0
- package/dist/nsAuthClient.d.ts.map +1 -0
- package/dist/nsAuthClient.js.map +1 -0
- package/dist/nsClient.d.ts +19 -0
- package/dist/nsClient.d.ts.map +1 -0
- package/dist/nsClient.js +40 -3
- package/dist/nsClient.js.map +1 -0
- package/dist/nsDevice.d.ts.map +1 -0
- package/dist/nsDevice.js.map +1 -0
- package/dist/nsSubscriptions.d.ts.map +1 -0
- package/dist/nsSubscriptions.js.map +1 -0
- package/dist/nsSynchronous.d.ts.map +1 -0
- package/dist/nsSynchronous.js.map +1 -0
- package/dist/nsWriteClient.d.ts.map +1 -0
- package/dist/nsWriteClient.js.map +1 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js.map +1 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js.map +1 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js.map +1 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js.map +1 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js.map +1 -0
- package/package.json +7 -3
- package/src/eligibility.selftest.ts +95 -0
- package/src/eligibility.ts +118 -0
- package/src/html.ts +407 -0
- package/src/index.ts +120 -0
- package/src/inventory.selftest.ts +213 -0
- package/src/inventory.ts +324 -0
- package/src/jwt.selftest.ts +145 -0
- package/src/jwt.ts +491 -0
- package/src/mermaid.ts +169 -0
- package/src/model.ts +130 -0
- package/src/nsAuthClient.selftest.ts +60 -0
- package/src/nsAuthClient.ts +102 -0
- package/src/nsClient.selftest.ts +173 -0
- package/src/nsClient.ts +323 -0
- package/src/nsDevice.selftest.ts +190 -0
- package/src/nsDevice.ts +167 -0
- package/src/nsSubscriptions.selftest.ts +486 -0
- package/src/nsSubscriptions.ts +638 -0
- package/src/nsSynchronous.selftest.ts +63 -0
- package/src/nsSynchronous.ts +98 -0
- package/src/nsWriteClient.selftest.ts +104 -0
- package/src/nsWriteClient.ts +157 -0
- package/src/policy.ts +123 -0
- package/src/principal.selftest.ts +118 -0
- package/src/principal.ts +101 -0
- package/src/raster.selftest.ts +42 -0
- package/src/raster.ts +79 -0
- package/src/resolver.selftest.ts +225 -0
- package/src/resolver.ts +1115 -0
- package/src/sensitivity.ts +40 -0
- package/src/themes.ts +142 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline test for evaluateEligibility — pure predicate, no fetch/mocking needed. Asserts precedence
|
|
3
|
+
* (hard > soft > precondition > ok): srv_code + non-3-4-digit ext are hard; excluded name/ext patterns
|
|
4
|
+
* are soft (reseller-overridable via force); missing email is a precondition; per-domain ext exclusion
|
|
5
|
+
* layers onto the global list. tsx src/eligibility.selftest.ts
|
|
6
|
+
*/
|
|
7
|
+
import { evaluateEligibility, type EligibilityConfig, type EligContext } from './index.js';
|
|
8
|
+
|
|
9
|
+
let pass = 0, fail = 0;
|
|
10
|
+
const ok = (c: boolean, m: string) => { c ? pass++ : fail++; console.log(`${c ? '✓' : '✗ FAIL'} ${m}`); };
|
|
11
|
+
|
|
12
|
+
const cfg = (over: Partial<EligibilityConfig> = {}): EligibilityConfig => ({
|
|
13
|
+
excludeNames: ['shared', 'fax'],
|
|
14
|
+
excludeExts: [],
|
|
15
|
+
excludeExtsByDomain: {},
|
|
16
|
+
excludeNoDevices: false,
|
|
17
|
+
resellerOverride: new Set(),
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
const ctx: EligContext = { domain: 'demo.12345.service', isReseller: false };
|
|
21
|
+
|
|
22
|
+
// ok — a normal user with an email
|
|
23
|
+
{
|
|
24
|
+
const r = evaluateEligibility({ ext: '100', email: 'a@example.com', names: ['Alice'] }, ctx, cfg());
|
|
25
|
+
ok(r.activatable === true && r.tier === 'ok' && r.reasons.length === 0, 'ok — a normal user with an email');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// hard — system/service user (srv_code) is never activatable
|
|
29
|
+
{
|
|
30
|
+
const r = evaluateEligibility({ ext: '100', email: 'a@example.com', srvCode: 'x' }, ctx, cfg());
|
|
31
|
+
ok(r.activatable === false && r.tier === 'hard', 'hard — system/service user (srv_code) is never activatable');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// hard — non 3-4 digit extension
|
|
35
|
+
{
|
|
36
|
+
const r = evaluateEligibility({ ext: '9001234', email: 'a@example.com' }, ctx, cfg());
|
|
37
|
+
ok(r.tier === 'hard', 'hard — non 3-4 digit extension');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// soft — excluded name pattern blocks by default
|
|
41
|
+
{
|
|
42
|
+
const r = evaluateEligibility({ ext: '100', email: 'a@example.com', names: ['SHARED VOICEMAIL'] }, ctx, cfg());
|
|
43
|
+
ok(r.tier === 'soft', 'soft — excluded name pattern blocks by default');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// soft overridden — reseller force bypasses the name rule
|
|
47
|
+
{
|
|
48
|
+
const r = evaluateEligibility(
|
|
49
|
+
{ ext: '100', email: 'a@example.com', names: ['FAX'] },
|
|
50
|
+
{ ...ctx, isReseller: true, force: true },
|
|
51
|
+
cfg(),
|
|
52
|
+
);
|
|
53
|
+
ok(r.activatable === true, 'soft overridden — reseller force bypasses the name rule');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// precondition — no email blocks even an otherwise-ok user
|
|
57
|
+
{
|
|
58
|
+
const r = evaluateEligibility({ ext: '100', names: ['Bob'] }, ctx, cfg());
|
|
59
|
+
ok(r.tier === 'precondition', 'precondition — no email blocks even an otherwise-ok user');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// per-domain ext exclusion applies
|
|
63
|
+
{
|
|
64
|
+
const c = cfg({ excludeExtsByDomain: { 'demo.12345.service': { add: ['100'] } } });
|
|
65
|
+
const r = evaluateEligibility({ ext: '100', email: 'a@example.com' }, ctx, c);
|
|
66
|
+
ok(r.tier === 'soft', 'per-domain ext exclusion applies');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// emailNotRequired — waives ONLY the email precondition (login-delivered credentials), and the waiver
|
|
70
|
+
// stays visible via emailWaived so a caller still knows there is no address to mail.
|
|
71
|
+
{
|
|
72
|
+
const r = evaluateEligibility({ ext: '100', names: ['Alice'] }, { ...ctx, emailNotRequired: true }, cfg());
|
|
73
|
+
ok(r.activatable === true && r.tier === 'ok', 'emailNotRequired — no email is eligible');
|
|
74
|
+
ok(r.emailWaived === true, 'emailNotRequired — the waiver is reported via emailWaived');
|
|
75
|
+
ok(r.reasons.length === 1, 'emailNotRequired — the missing address is still stated in reasons');
|
|
76
|
+
}
|
|
77
|
+
{
|
|
78
|
+
const r = evaluateEligibility({ ext: '100', email: 'a@example.com' }, { ...ctx, emailNotRequired: true }, cfg());
|
|
79
|
+
ok(r.tier === 'ok' && r.emailWaived === undefined, 'emailNotRequired with an address present — nothing waived');
|
|
80
|
+
}
|
|
81
|
+
{
|
|
82
|
+
const r = evaluateEligibility({ ext: '100', srvCode: 'x' }, { ...ctx, emailNotRequired: true }, cfg());
|
|
83
|
+
ok(r.tier === 'hard' && r.emailWaived === undefined, 'emailNotRequired does NOT rescue a HARD user');
|
|
84
|
+
}
|
|
85
|
+
{
|
|
86
|
+
const r = evaluateEligibility({ ext: '100', names: ['SHARED VOICEMAIL'] }, { ...ctx, emailNotRequired: true }, cfg());
|
|
87
|
+
ok(r.tier === 'soft' && r.emailWaived === undefined, 'emailNotRequired does NOT rescue a SOFT exclusion');
|
|
88
|
+
}
|
|
89
|
+
{
|
|
90
|
+
const r = evaluateEligibility({ ext: '100', names: ['Alice'] }, ctx, cfg());
|
|
91
|
+
ok(r.tier === 'precondition' && r.emailWaived === undefined, 'without the flag the precondition still fires');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
95
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evaluateEligibility — is a NetSapiens user a real end-user candidate for an app integration (Ringotel,
|
|
3
|
+
* or any other)? Pure and deployment-neutral. The name is generic on purpose: the config/rules define the
|
|
4
|
+
* purpose. Consumers supply an EligibilityConfig; env parsing lives in each consumer, never here.
|
|
5
|
+
*
|
|
6
|
+
* HARD — system/service users (srv_code) + structurally-invalid extensions. Never eligible, not even a
|
|
7
|
+
* reseller override.
|
|
8
|
+
* SOFT — name matchers, extension lists, no-device heuristic. Default-excluded, reseller-overridable
|
|
9
|
+
* per configured category (or via an explicit per-request `force`).
|
|
10
|
+
* email — a precondition: activation typically emails credentials, so it can't proceed without an address.
|
|
11
|
+
* Precedence: HARD → SOFT (names, exts) → precondition → ok.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type SoftCategory = 'names' | 'exts' | 'no_devices';
|
|
15
|
+
|
|
16
|
+
export interface EligibilityConfig {
|
|
17
|
+
/** Lowercased name-contains matchers (checked against first/last/display). Caller lowercases. */
|
|
18
|
+
excludeNames: string[];
|
|
19
|
+
/** Global extension exclusions (exact, or trailing-`*` prefix). */
|
|
20
|
+
excludeExts: string[];
|
|
21
|
+
/** Per-domain override of the extension list (add/remove relative to global). */
|
|
22
|
+
excludeExtsByDomain: Record<string, { add?: string[]; remove?: string[] }>;
|
|
23
|
+
/** No-device heuristic: TIGHTENS a name match (never decides alone). */
|
|
24
|
+
excludeNoDevices: boolean;
|
|
25
|
+
/** Soft categories a reseller may override. */
|
|
26
|
+
resellerOverride: Set<SoftCategory>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface EligUser {
|
|
30
|
+
ext: string;
|
|
31
|
+
srvCode?: string;
|
|
32
|
+
email?: string;
|
|
33
|
+
names?: string[];
|
|
34
|
+
deviceCount?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EligContext {
|
|
38
|
+
domain: string;
|
|
39
|
+
isReseller: boolean;
|
|
40
|
+
/** Reseller RUNTIME force: bypass ALL soft categories — never HARD, never the email precondition. */
|
|
41
|
+
force?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Credentials are delivered by LOGIN, not email, so the email precondition does not apply. Set this on
|
|
44
|
+
* an SSO/JIT path, where the account is created from the user's own directory credentials on first
|
|
45
|
+
* sign-in and nothing is mailed. It waives ONLY the email precondition — never HARD, never SOFT.
|
|
46
|
+
*
|
|
47
|
+
* The caller decides WHEN to set it; the engine only guarantees the outcome is the same everywhere it
|
|
48
|
+
* is set. A waived result stays distinguishable via `emailWaived`, so a caller can still branch on
|
|
49
|
+
* "eligible, but there is no address to mail anything to".
|
|
50
|
+
*/
|
|
51
|
+
emailNotRequired?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type EligTier = 'ok' | 'hard' | 'soft' | 'precondition';
|
|
55
|
+
export interface EligResult {
|
|
56
|
+
activatable: boolean;
|
|
57
|
+
tier: EligTier;
|
|
58
|
+
reasons: string[];
|
|
59
|
+
/**
|
|
60
|
+
* The user has no email address and `emailNotRequired` waived the precondition. `tier` is `'ok'` —
|
|
61
|
+
* they are eligible — but there is no address, so a caller must not try to mail them credentials.
|
|
62
|
+
* Absent whenever an address is present or the precondition was not reached.
|
|
63
|
+
*/
|
|
64
|
+
emailWaived?: true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const blank = (s?: string): boolean => !s || s.trim() === '';
|
|
68
|
+
|
|
69
|
+
function excludedExtsFor(config: EligibilityConfig, domain: string): string[] {
|
|
70
|
+
const dom = config.excludeExtsByDomain[domain] ?? {};
|
|
71
|
+
const set = new Set(config.excludeExts);
|
|
72
|
+
for (const a of dom.add ?? []) set.add(a);
|
|
73
|
+
for (const r of dom.remove ?? []) set.delete(r);
|
|
74
|
+
return [...set];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extMatch(ext: string, patterns: string[]): string | undefined {
|
|
78
|
+
return patterns.find((p) => (p.endsWith('*') ? ext.startsWith(p.slice(0, -1)) : ext === p));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function evaluateEligibility(user: EligUser, ctx: EligContext, config: EligibilityConfig): EligResult {
|
|
82
|
+
if (!blank(user.srvCode)) {
|
|
83
|
+
return { activatable: false, tier: 'hard', reasons: [`system/service user (srv_code="${user.srvCode!.trim()}")`] };
|
|
84
|
+
}
|
|
85
|
+
if (!/^\d{3,4}$/.test(user.ext)) {
|
|
86
|
+
return { activatable: false, tier: 'hard', reasons: [`extension "${user.ext}" is not a 3-4 digit user extension`] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const canOverride = (cat: SoftCategory): boolean => ctx.isReseller && (config.resellerOverride.has(cat) || !!ctx.force);
|
|
90
|
+
|
|
91
|
+
const names = (user.names ?? []).map((n) => (n || '').toLowerCase());
|
|
92
|
+
const nameMatch = config.excludeNames.find((m) => names.some((n) => n.includes(m)));
|
|
93
|
+
const nameHit = nameMatch && (!config.excludeNoDevices || (user.deviceCount ?? 0) === 0);
|
|
94
|
+
if (nameHit && !canOverride('names')) {
|
|
95
|
+
return { activatable: false, tier: 'soft', reasons: [`name matches excluded pattern "${nameMatch}"`] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const extHit = extMatch(user.ext, excludedExtsFor(config, ctx.domain));
|
|
99
|
+
if (extHit && !canOverride('exts')) {
|
|
100
|
+
return { activatable: false, tier: 'soft', reasons: [`extension "${user.ext}" matches excluded pattern "${extHit}"`] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (blank(user.email)) {
|
|
104
|
+
if (!ctx.emailNotRequired) {
|
|
105
|
+
return { activatable: false, tier: 'precondition', reasons: ['an email address is required to activate'] };
|
|
106
|
+
}
|
|
107
|
+
// Waived: credentials arrive by login, not mail. Eligible — but say the address is missing, so a
|
|
108
|
+
// caller that WOULD have mailed something can still tell.
|
|
109
|
+
return {
|
|
110
|
+
activatable: true,
|
|
111
|
+
tier: 'ok',
|
|
112
|
+
reasons: ['no email address (precondition waived: credentials are not emailed)'],
|
|
113
|
+
emailWaived: true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { activatable: true, tier: 'ok', reasons: [] };
|
|
118
|
+
}
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlowGraph[] -> a self-contained gallery HTML string. Portable (no Node deps): the caller
|
|
3
|
+
* decides where the string goes — a file (CLI), an HTTP response (Worker), or embedded in
|
|
4
|
+
* a host review page / build-preview. Mermaid renders client-side from a CDN.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FlowGraph } from './model.js';
|
|
8
|
+
import { toMermaid, type FlowTheme } from './mermaid.js';
|
|
9
|
+
|
|
10
|
+
// Pinned Mermaid build + Subresource Integrity. A floating `mermaid@11` tag lets jsDelivr serve whatever
|
|
11
|
+
// the latest 11.x is with NO integrity guarantee — a compromised/substituted CDN response would execute in
|
|
12
|
+
// the gallery (and, before the modal iframe was sandboxed, could reach the portal's ns_t). Pin an exact
|
|
13
|
+
// version + SRI hash so the browser refuses any bytes that don't match. Bump BOTH together — recompute:
|
|
14
|
+
// curl -s https://cdn.jsdelivr.net/npm/mermaid@<v>/dist/mermaid.min.js | openssl dgst -sha384 -binary | openssl base64 -A
|
|
15
|
+
const MERMAID_VERSION = '11.16.0';
|
|
16
|
+
const MERMAID_CDN = `https://cdn.jsdelivr.net/npm/mermaid@${MERMAID_VERSION}/dist/mermaid.min.js`;
|
|
17
|
+
const MERMAID_SRI = 'sha384-T/0lMUdJpd2S1ZHtRiofG3htU3xPCrFVeAQ1UUE2TJwlEJSV5NUwn30kP28n238E';
|
|
18
|
+
|
|
19
|
+
/** Build the Mermaid `<script>` tag. For the pinned jsDelivr build (the default) attach `integrity` +
|
|
20
|
+
* `crossorigin` so the browser rejects a substituted CDN payload. A caller-supplied self-hosted `src`
|
|
21
|
+
* is emitted WITHOUT SRI — the caller owns that origin's integrity, and its bytes won't match this hash. */
|
|
22
|
+
function mermaidScriptTag(src?: string): string {
|
|
23
|
+
const url = src ?? MERMAID_CDN;
|
|
24
|
+
const sri = url === MERMAID_CDN ? ` integrity="${MERMAID_SRI}" crossorigin="anonymous"` : '';
|
|
25
|
+
return `<script src="${escapeHtml(url)}"${sri}></script>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Left-align multi-line node content (agent / device / queue lists) + edge labels so they read as
|
|
29
|
+
* lists, not centered blobs. Hosts include this wherever `.mermaid` diagrams render. */
|
|
30
|
+
const FLOW_LABEL_CSS = `.mermaid g.agents div, .mermaid g.agents span, .mermaid g.agents p,
|
|
31
|
+
.mermaid g.devices div, .mermaid g.devices span, .mermaid g.devices p,
|
|
32
|
+
.mermaid g.queue div, .mermaid g.queue span, .mermaid g.queue p { text-align:left !important; }
|
|
33
|
+
.mermaid .edgeLabel div, .mermaid .edgeLabel span, .mermaid .edgeLabel p { text-align:left !important; }
|
|
34
|
+
/* Edge-label chips — keep them TIGHT. Mermaid gives the label foreignObject a tall line-height and a
|
|
35
|
+
semi-transparent .labelBkg; once overflow:visible stops the right-edge clip (it under-measures width
|
|
36
|
+
under look:neo, so "press 2" got cut), that block showed as an oversized blocky highlight and short
|
|
37
|
+
labels even wrapped. So: drop the block bg, force single-line (explicit <br/> in multi-option labels
|
|
38
|
+
still breaks), and render the text as a compact rounded chip with a little space before/after.
|
|
39
|
+
mermaid.ts drops the old trailing-nbsp slack in favor of this. */
|
|
40
|
+
.mermaid g.edgeLabel foreignObject { overflow:visible; }
|
|
41
|
+
.mermaid .edgeLabel .labelBkg { background:transparent !important; }
|
|
42
|
+
.mermaid .edgeLabel foreignObject > div { white-space:nowrap !important; line-height:1.35 !important; max-width:none !important; }
|
|
43
|
+
.mermaid span.edgeLabel { display:inline-block; padding:1px 7px; border-radius:4px; line-height:1.35; white-space:nowrap; }`;
|
|
44
|
+
|
|
45
|
+
/** Shared flowchart layout config. Node clipping is prevented by nbsp label slack in mermaid.ts,
|
|
46
|
+
* not by padding — so this keeps Mermaid's default node padding (a larger override made wide nodes
|
|
47
|
+
* like AAs balloon). */
|
|
48
|
+
const FLOWCHART_CFG = `htmlLabels:true, curve:'basis', nodeSpacing:45, rankSpacing:55`;
|
|
49
|
+
|
|
50
|
+
/** Escapes for BOTH text and quoted-attribute contexts — `mermaidScriptTag` interpolates into
|
|
51
|
+
* `src="…"`, so omitting the quote entities made `escapeHtml(url)` look safe while allowing
|
|
52
|
+
* `x.js" onload="…`. Over-escaping in text position is inert, so one function covers both. */
|
|
53
|
+
function escapeHtml(t: string): string {
|
|
54
|
+
return String(t)
|
|
55
|
+
.replace(/&/g, '&')
|
|
56
|
+
.replace(/</g, '<')
|
|
57
|
+
.replace(/>/g, '>')
|
|
58
|
+
.replace(/"/g, '"')
|
|
59
|
+
.replace(/'/g, ''');
|
|
60
|
+
}
|
|
61
|
+
function cap(t: string): string {
|
|
62
|
+
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
63
|
+
}
|
|
64
|
+
/** Stable, collision-safe DOM id for a flow card so a host page can deep-link one diagram.
|
|
65
|
+
* `kind` is sanitized alongside `ref`: resolveFlow only ever emits the four literals, but
|
|
66
|
+
* FlowGraph.entity.kind is typed `string` and hand-built graphs are a supported use, so an
|
|
67
|
+
* unsanitized kind would land unescaped in `id="…"`/`href="#…"`. */
|
|
68
|
+
export function flowAnchorId(g: FlowGraph): string {
|
|
69
|
+
const safe = (s: unknown) => String(s).replace(/[^A-Za-z0-9_-]/g, '-');
|
|
70
|
+
return `flow-${safe(g.entity.kind)}-${safe(g.entity.ref)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A CSS color safe to interpolate into a `<style>` block. Anything else is refused rather than
|
|
74
|
+
* escaped: `accent` is documented as host-supplied config, and a white-label host that sources it
|
|
75
|
+
* per-tenant would otherwise hand any tenant a `</style><script>` breakout. Callers that were
|
|
76
|
+
* already passing hex (the documented contract) see no change. */
|
|
77
|
+
function safeAccent(accent: string | undefined, fallback: string): string {
|
|
78
|
+
return accent && /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CardOptions {
|
|
82
|
+
/** Themed rendering (light/dark palette + `look: neo` + a card anchor id). OMIT for the
|
|
83
|
+
* legacy dark card with no frontmatter and no id — the Worker relies on that being unchanged. */
|
|
84
|
+
theme?: FlowTheme;
|
|
85
|
+
/** Render the card as a collapsible `<details>` (gallery navigation). */
|
|
86
|
+
collapsible?: boolean;
|
|
87
|
+
/** When collapsible, start expanded. */
|
|
88
|
+
open?: boolean;
|
|
89
|
+
/** When collapsible, add a "↑ top" link in the summary. */
|
|
90
|
+
backToTop?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface GalleryOptions extends CardOptions {
|
|
94
|
+
/** Load Mermaid from this URL (CDN by default). Override to self-host / inline for CSP. */
|
|
95
|
+
mermaidSrc?: string;
|
|
96
|
+
/** Extra note shown under the title. */
|
|
97
|
+
subtitle?: string;
|
|
98
|
+
/** Brand accent for subtle highlights — links, the "Legend:" lead, the card's top rule, and the
|
|
99
|
+
* pan/zoom controls' hover. Themed path only; defaults to the theme's link color. Pass your own
|
|
100
|
+
* brand color here (from your host's config) rather than baking one into a theme, e.g.
|
|
101
|
+
* `accent: '#1a6bb0'`.
|
|
102
|
+
*
|
|
103
|
+
* MUST be a CSS hex color (`#rgb` … `#rrggbbaa`); this value is interpolated into a `<style>`
|
|
104
|
+
* block, so anything else is IGNORED in favour of the theme's link color rather than escaped.
|
|
105
|
+
* If you source it per-tenant, that rejection is the only thing between a hostile value and a
|
|
106
|
+
* `</style><script>` breakout — don't defeat it by pre-formatting the string. */
|
|
107
|
+
accent?: string;
|
|
108
|
+
/** Themed galleries only: anchor ids ({@link flowAnchorId}) to render pre-expanded. Cards not in
|
|
109
|
+
* the set render collapsed. A themed gallery is always collapsible with a table-of-contents. */
|
|
110
|
+
expand?: Set<string>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Render one flow as a gallery `<section>` card. Themed cards carry an `id` anchor
|
|
114
|
+
* ({@link flowAnchorId}); the legacy (no-theme) card is byte-identical to the original. */
|
|
115
|
+
export function renderFlowCard(g: FlowGraph, opts: CardOptions = {}): string {
|
|
116
|
+
const notes = g.notes.length ? `<ul class="cf-notes">${g.notes.map((n) => `<li>${escapeHtml(n)}</li>`).join('')}</ul>` : '';
|
|
117
|
+
const mermaid = `<div class="mermaid">${escapeHtml(toMermaid(g, opts.theme ? { theme: opts.theme } : undefined))}</div>`;
|
|
118
|
+
const title = `${escapeHtml(g.entity.kind === 'did' ? 'DID' : cap(g.entity.kind))}: ${escapeHtml(g.entity.label)}`;
|
|
119
|
+
if (opts.collapsible) {
|
|
120
|
+
const back = opts.backToTop ? ` <a class="cf-top" href="#cf-top" onclick="event.stopPropagation()">↑ top</a>` : '';
|
|
121
|
+
return `<details class="cf-card"${opts.theme ? ` id="${flowAnchorId(g)}"` : ''}${opts.open ? ' open' : ''}>
|
|
122
|
+
<summary><span class="cf-title">${title}</span><span class="cf-meta"> · ${g.nodes.length} nodes · ${g.edges.length} edges</span>${back}</summary>
|
|
123
|
+
${mermaid}
|
|
124
|
+
${notes}
|
|
125
|
+
</details>`;
|
|
126
|
+
}
|
|
127
|
+
const idAttr = opts.theme ? ` id="${flowAnchorId(g)}"` : '';
|
|
128
|
+
return `<section class="cf-card"${idAttr}>
|
|
129
|
+
<h2>${title}</h2>
|
|
130
|
+
<div class="cf-meta">${g.nodes.length} nodes · ${g.edges.length} edges</div>
|
|
131
|
+
${mermaid}
|
|
132
|
+
${notes}
|
|
133
|
+
</section>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Render the gallery `<section>` cards only (no page chrome) — for embedding in another page. */
|
|
137
|
+
export function renderFlowCards(graphs: FlowGraph[], opts: CardOptions = {}): string {
|
|
138
|
+
return graphs.map((g) => renderFlowCard(g, opts)).join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The `<script>` tags a host page needs to render embedded `.mermaid` blocks itself
|
|
143
|
+
* (e.g. a review report). `securityLevel: 'strict'` is MANDATORY — it is the
|
|
144
|
+
* second escaping layer `mermaid.ts` depends on; do not make it caller-configurable.
|
|
145
|
+
*/
|
|
146
|
+
export function mermaidBootstrap(opts: { theme?: FlowTheme; mermaidSrc?: string } = {}): string {
|
|
147
|
+
const mermaidTheme = opts.theme === 'light' ? 'base' : 'dark';
|
|
148
|
+
return `<style>${FLOW_LABEL_CSS}</style>
|
|
149
|
+
${mermaidScriptTag(opts.mermaidSrc)}
|
|
150
|
+
<script>mermaid.initialize({ startOnLoad:true, theme:'${mermaidTheme}', securityLevel:'strict', flowchart:{ ${FLOWCHART_CFG} } });</script>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Full standalone HTML document for a set of flows. */
|
|
154
|
+
export function renderGalleryHtml(domain: string, graphs: FlowGraph[], opts: GalleryOptions = {}): string {
|
|
155
|
+
const mermaidSrc = opts.mermaidSrc ?? MERMAID_CDN;
|
|
156
|
+
const subtitle = opts.subtitle ?? `resolved from snapshot · ${graphs.length} flows`;
|
|
157
|
+
// Themed path (light for a review context, or explicit dark-neo). No theme → the original dark
|
|
158
|
+
// document below, byte-identical (the Worker depends on this).
|
|
159
|
+
if (opts.theme) return themedGalleryHtml(domain, graphs, mermaidSrc, subtitle, opts.theme, opts.expand, opts.accent);
|
|
160
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
161
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
162
|
+
<title>Call Flows — ${escapeHtml(domain)}</title>
|
|
163
|
+
${mermaidScriptTag(mermaidSrc)}
|
|
164
|
+
<style>
|
|
165
|
+
:root { color-scheme: dark; }
|
|
166
|
+
body { background:#0f1115; color:#e6e6e6; font:15px/1.5 system-ui,sans-serif; margin:0; padding:24px; }
|
|
167
|
+
h1 { font-size:14px; font-weight:600; color:#8a94a6; margin:0 0 2px; }
|
|
168
|
+
.cf-sub { color:#e6e6e6; margin:0 0 24px; font-size:18px; font-weight:700; }
|
|
169
|
+
.cf-card { background:#161a22; border:1px solid #232a36; border-radius:12px; padding:18px 20px; margin:0 0 22px; }
|
|
170
|
+
.cf-card h2 { font-size:16px; margin:0 0 2px; }
|
|
171
|
+
.cf-meta { color:#7b8494; font-size:12px; margin-bottom:12px; }
|
|
172
|
+
.mermaid { background:#0c0e12; border-radius:8px; padding:14px; overflow:auto; text-align:center; }
|
|
173
|
+
${FLOW_LABEL_CSS}
|
|
174
|
+
.cf-notes { margin:12px 0 0; padding-left:18px; color:#c9a24a; font-size:12.5px; }
|
|
175
|
+
.cf-notes li { margin:2px 0; }
|
|
176
|
+
.cf-legend { display:flex; align-items:center; flex-wrap:wrap; gap:10px; margin:0 0 22px; font-size:12px; color:#aab; }
|
|
177
|
+
.cf-legend span { background:#161a22; border:1px solid #232a36; border-radius:6px; padding:3px 8px; }
|
|
178
|
+
.cf-legend-lead { font-weight:700; }
|
|
179
|
+
.mermaid { cursor:zoom-in; }
|
|
180
|
+
.cf-lightbox { position:fixed; inset:0; background:rgba(6,8,12,.95); display:none; z-index:9999; padding:20px; cursor:zoom-out; }
|
|
181
|
+
.cf-lightbox.open { display:block; }
|
|
182
|
+
.cf-lightbox-inner { width:100%; height:100%; overflow:auto; display:flex; align-items:flex-start; justify-content:center; }
|
|
183
|
+
.cf-lightbox-inner svg { max-width:none !important; height:auto; }
|
|
184
|
+
.cf-lightbox-hint { position:fixed; top:10px; right:16px; color:#8a94a6; font-size:12px; pointer-events:none; }
|
|
185
|
+
</style></head>
|
|
186
|
+
<body>
|
|
187
|
+
<h1>Call Flow Diagram — ${escapeHtml(domain)}</h1>
|
|
188
|
+
<p class="cf-sub">${escapeHtml(subtitle)}</p>
|
|
189
|
+
<div class="cf-legend">
|
|
190
|
+
<b class="cf-legend-lead">Legend:</b><span>📞 DID</span><span>🕒 time-of-day</span><span>👤 user</span><span>📱 ring devices</span>
|
|
191
|
+
<span>📋 queue</span><span>👥 agents</span><span>🔀 auto attendant</span><span>🔊 prompt</span>
|
|
192
|
+
<span>📭 voicemail</span><span>☎️ external</span>
|
|
193
|
+
</div>
|
|
194
|
+
${renderFlowCards(graphs)}
|
|
195
|
+
<div id="cf-lightbox" class="cf-lightbox"><span class="cf-lightbox-hint">click / Esc to close</span><div class="cf-lightbox-inner"></div></div>
|
|
196
|
+
<script>
|
|
197
|
+
mermaid.initialize({ startOnLoad:true, theme:'dark', securityLevel:'strict', flowchart:{ ${FLOWCHART_CFG} } });
|
|
198
|
+
// Click any diagram to enlarge it in a scrollable overlay; click anywhere / Esc to close.
|
|
199
|
+
(function(){
|
|
200
|
+
var box = document.getElementById('cf-lightbox');
|
|
201
|
+
var inner = box.querySelector('.cf-lightbox-inner');
|
|
202
|
+
function close(){ box.classList.remove('open'); inner.replaceChildren(); }
|
|
203
|
+
document.addEventListener('click', function(e){
|
|
204
|
+
var card = e.target.closest ? e.target.closest('.mermaid') : null;
|
|
205
|
+
if (card && !box.contains(card)) {
|
|
206
|
+
var svg = card.querySelector('svg');
|
|
207
|
+
if (!svg) return;
|
|
208
|
+
var clone = svg.cloneNode(true);
|
|
209
|
+
clone.removeAttribute('height'); clone.removeAttribute('style');
|
|
210
|
+
inner.replaceChildren(clone);
|
|
211
|
+
box.classList.add('open');
|
|
212
|
+
} else if (box.classList.contains('open')) {
|
|
213
|
+
close();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
|
|
217
|
+
})();
|
|
218
|
+
</script>
|
|
219
|
+
</body></html>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Themed gallery document — light (review context) or explicit dark-neo. Parallel to
|
|
223
|
+
* the legacy dark document in {@link renderGalleryHtml}; kept separate so that path stays byte-identical. */
|
|
224
|
+
function themedGalleryHtml(
|
|
225
|
+
domain: string,
|
|
226
|
+
graphs: FlowGraph[],
|
|
227
|
+
mermaidSrc: string,
|
|
228
|
+
subtitle: string,
|
|
229
|
+
theme: FlowTheme,
|
|
230
|
+
expand?: Set<string>,
|
|
231
|
+
accent?: string,
|
|
232
|
+
): string {
|
|
233
|
+
const light = theme === 'light';
|
|
234
|
+
const c = light
|
|
235
|
+
? { scheme: 'light', pageBg: '#fafafa', text: '#1e293b', sub: '#64748b', cardBg: '#ffffff', cardBorder: '#e2e8f0', meta: '#64748b', mermaidBg: '#f8fafc', notes: '#b45309', legendText: '#475569', legendBg: '#ffffff', lightboxBg: 'rgba(248,250,252,.96)', hint: '#64748b', shadow: 'box-shadow:0 1px 2px rgba(0,0,0,.04);', link: '#21618c' }
|
|
236
|
+
: { scheme: 'dark', pageBg: '#0f1115', text: '#e6e6e6', sub: '#8a94a6', cardBg: '#161a22', cardBorder: '#232a36', meta: '#7b8494', mermaidBg: '#0c0e12', notes: '#c9a24a', legendText: '#aab', legendBg: '#161a22', lightboxBg: 'rgba(6,8,12,.95)', hint: '#8a94a6', shadow: '', link: '#7db3e6' };
|
|
237
|
+
const initTheme = light ? 'base' : 'dark';
|
|
238
|
+
const brandAccent = safeAccent(accent, c.link);
|
|
239
|
+
const single = graphs.length === 1; // a one-flow gallery (e.g. the portal modal) needs no contents nav
|
|
240
|
+
const isOpen = (g: FlowGraph) => single || (!!expand && expand.has(flowAnchorId(g)));
|
|
241
|
+
|
|
242
|
+
// Table of contents grouped by entity kind; ● marks the pre-expanded (notable) flows.
|
|
243
|
+
const KIND_LABEL: Record<string, string> = { did: '📞 DIDs', user: '👤 Users', queue: '📋 Queues', attendant: '🔀 Auto Attendants' };
|
|
244
|
+
const byKind = new Map<string, FlowGraph[]>();
|
|
245
|
+
for (const g of graphs) {
|
|
246
|
+
const k = g.entity.kind;
|
|
247
|
+
if (!byKind.has(k)) byKind.set(k, []);
|
|
248
|
+
byKind.get(k)!.push(g);
|
|
249
|
+
}
|
|
250
|
+
const toc = [...byKind.entries()]
|
|
251
|
+
.map(
|
|
252
|
+
([kind, gs]) => `<div class="cf-toc-group"><div class="cf-toc-h">${KIND_LABEL[kind] ?? escapeHtml(cap(kind))}</div>
|
|
253
|
+
<ul>${gs
|
|
254
|
+
.map((g) => `<li>${isOpen(g) ? '<b>●</b> ' : ''}<a href="#${flowAnchorId(g)}">${escapeHtml(g.entity.label)}</a></li>`)
|
|
255
|
+
.join('')}</ul></div>`,
|
|
256
|
+
)
|
|
257
|
+
.join('\n');
|
|
258
|
+
|
|
259
|
+
// A single-flow gallery (portal modal) renders as a fixed, non-collapsible card; multi-flow keeps the
|
|
260
|
+
// collapsible <details> for navigation.
|
|
261
|
+
const cards = graphs.map((g) => renderFlowCard(g, { theme, collapsible: !single, open: isOpen(g), backToTop: !single })).join('\n');
|
|
262
|
+
|
|
263
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
264
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
265
|
+
<title>Call Flows — ${escapeHtml(domain)}</title>
|
|
266
|
+
${mermaidScriptTag(mermaidSrc)}
|
|
267
|
+
<style>
|
|
268
|
+
:root { color-scheme: ${c.scheme}; scroll-behavior:smooth; }
|
|
269
|
+
body { background:${c.pageBg}; color:${c.text}; font:15px/1.5 system-ui,sans-serif; margin:0; padding:24px; }
|
|
270
|
+
a { color:${brandAccent}; }
|
|
271
|
+
h1 { font-size:14px; font-weight:600; color:${c.sub}; margin:0 0 2px; }
|
|
272
|
+
.cf-sub { color:${c.text}; margin:0 0 18px; font-size:18px; font-weight:700; }
|
|
273
|
+
.cf-legend { display:flex; align-items:center; flex-wrap:wrap; gap:10px; margin:0 0 18px; font-size:12px; color:${c.legendText}; }
|
|
274
|
+
.cf-legend span { background:${c.legendBg}; border:1px solid ${c.cardBorder}; border-radius:6px; padding:3px 8px; }
|
|
275
|
+
.cf-legend-lead { font-weight:700; color:${brandAccent}; }
|
|
276
|
+
.cf-toc { background:${c.cardBg}; border:1px solid ${c.cardBorder}; border-radius:12px; padding:14px 18px; margin:0 0 22px; ${c.shadow} }
|
|
277
|
+
.cf-toc-top { font-weight:700; font-size:13px; margin:0 0 8px; }
|
|
278
|
+
.cf-toc-cols { display:flex; flex-wrap:wrap; gap:8px 28px; }
|
|
279
|
+
.cf-toc-group { min-width:160px; }
|
|
280
|
+
.cf-toc-h { font-size:12px; font-weight:700; color:${c.sub}; margin:4px 0 2px; }
|
|
281
|
+
.cf-toc ul { margin:0 0 6px; padding-left:16px; font-size:13px; }
|
|
282
|
+
.cf-toc li { margin:1px 0; }
|
|
283
|
+
.cf-card { background:${c.cardBg}; border:1px solid ${c.cardBorder}; border-radius:12px; padding:0; margin:0 0 14px; ${c.shadow} scroll-margin-top:12px; }
|
|
284
|
+
section.cf-card { padding:12px 18px; border-top:3px solid ${brandAccent}; }
|
|
285
|
+
section.cf-card > h2 { font-size:16px; margin:0 0 2px; }
|
|
286
|
+
details.cf-card > summary { cursor:pointer; padding:12px 18px; font-size:15px; font-weight:700; list-style:none; user-select:none; }
|
|
287
|
+
details.cf-card > summary::-webkit-details-marker { display:none; }
|
|
288
|
+
details.cf-card > summary::before { content:'▸ '; color:${c.sub}; font-weight:400; }
|
|
289
|
+
details.cf-card[open] > summary::before { content:'▾ '; }
|
|
290
|
+
details.cf-card[open] > summary { border-bottom:1px solid ${c.cardBorder}; }
|
|
291
|
+
.cf-title { }
|
|
292
|
+
.cf-meta { color:${c.meta}; font-size:12px; font-weight:400; }
|
|
293
|
+
.cf-top { float:right; font-size:11px; font-weight:400; }
|
|
294
|
+
details.cf-card > .mermaid, details.cf-card > .cf-notes { margin:0 18px; }
|
|
295
|
+
.mermaid { background:${c.mermaidBg}; border-radius:8px; padding:14px; margin:14px 0; overflow:auto; text-align:center; cursor:zoom-in; }
|
|
296
|
+
.mermaid.cf-pz { overflow:hidden; cursor:grab; position:relative; text-align:left; padding:0; height:70vh; touch-action:none; }
|
|
297
|
+
.mermaid.cf-pz svg { position:absolute; top:0; left:0; max-width:none !important; height:auto; }
|
|
298
|
+
.cf-pz-ctl { position:absolute; top:10px; right:10px; display:flex; flex-direction:column; gap:5px; z-index:5; }
|
|
299
|
+
.cf-pz-ctl button { width:30px; height:30px; border:1px solid ${c.cardBorder}; background:${c.cardBg}; color:${c.text}; border-radius:6px; cursor:pointer; font:16px/1 system-ui,sans-serif; ${c.shadow} }
|
|
300
|
+
.cf-pz-ctl button:hover { border-color:${brandAccent}; color:${brandAccent}; }
|
|
301
|
+
${FLOW_LABEL_CSS}
|
|
302
|
+
.cf-notes { padding:0 0 14px 34px; color:${c.notes}; font-size:12.5px; }
|
|
303
|
+
.cf-notes li { margin:2px 0; }
|
|
304
|
+
.cf-lightbox { position:fixed; inset:0; background:${c.lightboxBg}; display:none; z-index:9999; padding:20px; cursor:zoom-out; }
|
|
305
|
+
.cf-lightbox.open { display:block; }
|
|
306
|
+
.cf-lightbox-inner { width:100%; height:100%; overflow:auto; display:flex; align-items:flex-start; justify-content:center; }
|
|
307
|
+
.cf-lightbox-inner svg { max-width:none !important; height:auto; }
|
|
308
|
+
.cf-lightbox-hint { position:fixed; top:10px; right:16px; color:${c.hint}; font-size:12px; pointer-events:none; }
|
|
309
|
+
</style></head>
|
|
310
|
+
<body>
|
|
311
|
+
<span id="cf-top"></span>
|
|
312
|
+
<h1>Call Flow Diagram — ${escapeHtml(domain)}</h1>
|
|
313
|
+
<p class="cf-sub">${escapeHtml(subtitle)}</p>
|
|
314
|
+
<div class="cf-legend">
|
|
315
|
+
<b class="cf-legend-lead">Legend:</b><span>📞 DID</span><span>🕒 time-of-day</span><span>👤 user</span><span>📱 ring devices</span>
|
|
316
|
+
<span>📋 queue</span><span>👥 agents</span><span>🔀 auto attendant</span><span>🔊 prompt</span>
|
|
317
|
+
<span>📭 voicemail</span><span>☎️ external</span>
|
|
318
|
+
</div>
|
|
319
|
+
${single ? '' : `<nav class="cf-toc">
|
|
320
|
+
<div class="cf-toc-top">Contents <span class="cf-meta">(● = notable / pre-expanded)</span></div>
|
|
321
|
+
<div class="cf-toc-cols">${toc}</div>
|
|
322
|
+
</nav>`}
|
|
323
|
+
${cards}
|
|
324
|
+
<div id="cf-lightbox" class="cf-lightbox"><span class="cf-lightbox-hint">click / Esc to close</span><div class="cf-lightbox-inner"></div></div>
|
|
325
|
+
${mermaidBootstrapInline(initTheme)}
|
|
326
|
+
</body></html>`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The gallery's own Mermaid init + lightbox script (self-contained; not the host-page bootstrap). */
|
|
330
|
+
function mermaidBootstrapInline(initTheme: string): string {
|
|
331
|
+
return `<script>
|
|
332
|
+
mermaid.initialize({ startOnLoad:false, theme:'${initTheme}', securityLevel:'strict', flowchart:{ ${FLOWCHART_CFG} } });
|
|
333
|
+
document.querySelectorAll('.mermaid').forEach(function(el){ if(!el.getAttribute('data-src')){ el.setAttribute('data-src', el.textContent); } });
|
|
334
|
+
mermaid.run({ querySelector:'.mermaid' }).then(function(){
|
|
335
|
+
var els = document.querySelectorAll('.mermaid');
|
|
336
|
+
// Single-flow (the portal modal): scroll/drag pan, Shift+scroll or +/- buttons zoom, flip layout.
|
|
337
|
+
if (els.length === 1) { panZoom(els[0]); } else { lightbox(); }
|
|
338
|
+
});
|
|
339
|
+
function panZoom(el){
|
|
340
|
+
var svg = el.querySelector('svg'); if(!svg) return;
|
|
341
|
+
el.classList.add('cf-pz');
|
|
342
|
+
// Pin the SVG to its natural viewBox pixel size so our transform (not mermaid's width:100% +
|
|
343
|
+
// viewBox auto-scaling) fully controls the displayed size — otherwise fit measurements are wrong.
|
|
344
|
+
var vb=(svg.viewBox&&svg.viewBox.baseVal)||{};
|
|
345
|
+
var natW=vb.width||svg.getBoundingClientRect().width, natH=vb.height||svg.getBoundingClientRect().height;
|
|
346
|
+
svg.setAttribute('width', natW); svg.setAttribute('height', natH); svg.style.maxWidth='none'; svg.style.transformOrigin='0 0';
|
|
347
|
+
var st = el._pz || (el._pz = {}); st.svg=svg; st.natW=natW; st.natH=natH; st.k=1; st.tx=0; st.ty=0; st.drag=false;
|
|
348
|
+
st.apply = function(){ svg.style.transform='translate('+st.tx+'px,'+st.ty+'px) scale('+st.k+')'; };
|
|
349
|
+
st.fit = function(){
|
|
350
|
+
var r=el.getBoundingClientRect(), w=st.natW||r.width, h=st.natH||r.height;
|
|
351
|
+
var pad=16, lr=/flowchart\\s+(LR|RL)/.test(el.getAttribute('data-src')||'');
|
|
352
|
+
// Fill the primary axis (TD → width, LR → height); never upscale past natural (1x) so small
|
|
353
|
+
// few-node diagrams don't balloon. Center the cross-axis; start-align the overflowing one.
|
|
354
|
+
st.k=Math.min(lr?(r.height-pad)/h:(r.width-pad)/w, 1); if(!isFinite(st.k)||st.k<=0){ st.k=1; }
|
|
355
|
+
if(lr){ st.ty=(r.height-h*st.k)/2; st.tx=(w*st.k>r.width)?pad:(r.width-w*st.k)/2; }
|
|
356
|
+
else { st.tx=(r.width-w*st.k)/2; st.ty=(h*st.k>r.height)?pad:(r.height-h*st.k)/2; }
|
|
357
|
+
st.apply();
|
|
358
|
+
};
|
|
359
|
+
st.zoomAt = function(f, cx, cy){ var nk=Math.max(0.2,Math.min(6,st.k*f)), r=nk/st.k; st.tx=cx-(cx-st.tx)*r; st.ty=cy-(cy-st.ty)*r; st.k=nk; st.apply(); };
|
|
360
|
+
function flip(){
|
|
361
|
+
var src=el.getAttribute('data-src')||'';
|
|
362
|
+
var cur=(src.match(/flowchart\\s+(TB|TD|LR|RL|BT)/)||[])[1]||'TD';
|
|
363
|
+
var next=(cur==='LR'||cur==='RL')?'TD':'LR';
|
|
364
|
+
el.setAttribute('data-src', src.replace(/flowchart\\s+(TB|TD|LR|RL|BT)/, 'flowchart '+next));
|
|
365
|
+
var oc=el.querySelector('.cf-pz-ctl'); if(oc){ oc.remove(); }
|
|
366
|
+
el.classList.remove('cf-pz'); el.removeAttribute('data-processed'); el.textContent=el.getAttribute('data-src');
|
|
367
|
+
mermaid.run({ nodes:[el] }).then(function(){ panZoom(el); });
|
|
368
|
+
}
|
|
369
|
+
var oldc=el.querySelector('.cf-pz-ctl'); if(oldc){ oldc.remove(); }
|
|
370
|
+
var ctl=document.createElement('div'); ctl.className='cf-pz-ctl';
|
|
371
|
+
function mk(t,tip,f){ var b=document.createElement('button'); b.type='button'; b.textContent=t; b.title=tip; b.addEventListener('click', function(e){ e.stopPropagation(); f(); }); ctl.appendChild(b); }
|
|
372
|
+
mk('+','Zoom in', function(){ st.zoomAt(1.2, el.clientWidth/2, 0); });
|
|
373
|
+
mk('−','Zoom out', function(){ st.zoomAt(1/1.2, el.clientWidth/2, 0); });
|
|
374
|
+
mk('↺','Reset view', function(){ st.fit(); });
|
|
375
|
+
mk('⇄','Flip layout (horizontal / vertical)', flip);
|
|
376
|
+
el.appendChild(ctl);
|
|
377
|
+
if(!el._pzBound){
|
|
378
|
+
el._pzBound=true;
|
|
379
|
+
el.addEventListener('wheel', function(e){
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
if(e.shiftKey){ var d=e.deltaY||e.deltaX, r=el.getBoundingClientRect(); st.zoomAt(d<0?1.1:1/1.1, e.clientX-r.left, e.clientY-r.top); }
|
|
382
|
+
else { st.tx-=e.deltaX; st.ty-=e.deltaY; st.apply(); }
|
|
383
|
+
}, {passive:false});
|
|
384
|
+
el.addEventListener('pointerdown', function(e){ if(e.target.closest && e.target.closest('.cf-pz-ctl')){ return; } st.drag=true; st.px=e.clientX; st.py=e.clientY; try{ el.setPointerCapture(e.pointerId); }catch(_){} el.style.cursor='grabbing'; });
|
|
385
|
+
el.addEventListener('pointermove', function(e){ if(!st.drag){ return; } st.tx+=e.clientX-st.px; st.ty+=e.clientY-st.py; st.px=e.clientX; st.py=e.clientY; st.apply(); });
|
|
386
|
+
var end=function(){ st.drag=false; el.style.cursor='grab'; };
|
|
387
|
+
el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
|
|
388
|
+
window.addEventListener('resize', function(){ if(st.fit){ st.fit(); } });
|
|
389
|
+
}
|
|
390
|
+
requestAnimationFrame(function(){ st.fit(); }); setTimeout(function(){ st.fit(); }, 80);
|
|
391
|
+
}
|
|
392
|
+
function lightbox(){
|
|
393
|
+
var box = document.getElementById('cf-lightbox'); if(!box){ return; }
|
|
394
|
+
var inner = box.querySelector('.cf-lightbox-inner');
|
|
395
|
+
function close(){ box.classList.remove('open'); inner.replaceChildren(); }
|
|
396
|
+
document.addEventListener('click', function(e){
|
|
397
|
+
var card = e.target.closest ? e.target.closest('.mermaid') : null;
|
|
398
|
+
if (card && !box.contains(card)) {
|
|
399
|
+
var svg = card.querySelector('svg'); if (!svg) return;
|
|
400
|
+
var clone = svg.cloneNode(true); clone.removeAttribute('height'); clone.removeAttribute('style');
|
|
401
|
+
inner.replaceChildren(clone); box.classList.add('open');
|
|
402
|
+
} else if (box.classList.contains('open')) { close(); }
|
|
403
|
+
});
|
|
404
|
+
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
|
|
405
|
+
}
|
|
406
|
+
</script>`;
|
|
407
|
+
}
|