dsh-knowhub 0.9.0 → 0.10.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/README.md +11 -2
- package/cordis.patch.yml +10 -0
- package/lib/host/model.js +1 -1
- package/lib/host/pipeline/incremental.js +3 -3
- package/lib/host/pipeline/initialize.js +144 -27
- package/lib/host/pipeline/model-output.js +2 -2
- package/package.json +3 -2
- package/skills/archify/LICENSE +202 -0
- package/skills/archify/LICENSE-MIT +22 -0
- package/skills/archify/NOTICE +26 -0
- package/skills/archify/SKILL.md +137 -0
- package/skills/archify/THIRD_PARTY_NOTICES.md +78 -0
- package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
- package/skills/archify/assets/template.html +14768 -0
- package/skills/archify/bin/archify.mjs +7289 -0
- package/skills/archify/bin/delivery-update.mjs +92 -0
- package/skills/archify/bin/finalize.mjs +1055 -0
- package/skills/archify/bin/open-artifact.mjs +146 -0
- package/skills/archify/bin/preview.mjs +970 -0
- package/skills/archify/bin/recover-output.mjs +41 -0
- package/skills/archify/bin/visual-check.mjs +2780 -0
- package/skills/archify/brand-marks/README.md +31 -0
- package/skills/archify/brand-marks/catalog.json +131 -0
- package/skills/archify/delta/architecture-delta.mjs +1318 -0
- package/skills/archify/examples/agent-run.lifecycle.json +55 -0
- package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
- package/skills/archify/examples/archify-dev-activity.timeline.json +176 -0
- package/skills/archify/examples/archify-repository.tree.json +197 -0
- package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
- package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
- package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
- package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
- package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
- package/skills/archify/examples/checkout-request.waterfall.json +20 -0
- package/skills/archify/examples/delivery.explainer.json +155 -0
- package/skills/archify/examples/deployment-release.lifecycle.json +72 -0
- package/skills/archify/examples/event-stream.dataflow.json +52 -0
- package/skills/archify/examples/example-rebuild.waterfall.json +212 -0
- package/skills/archify/examples/holiday-planning.workflow.json +143 -0
- package/skills/archify/examples/incident-response.workflow.json +59 -0
- package/skills/archify/examples/locales/de.json +463 -0
- package/skills/archify/examples/locales/fr.json +463 -0
- package/skills/archify/examples/locales/fr.partial.json +22 -0
- package/skills/archify/examples/locales/it.json +463 -0
- package/skills/archify/examples/locales/ja.json +463 -0
- package/skills/archify/examples/locales/pt.json +463 -0
- package/skills/archify/examples/locales/ru.json +463 -0
- package/skills/archify/examples/orders.erd.json +337 -0
- package/skills/archify/examples/payment-incident.timeline.json +24 -0
- package/skills/archify/examples/payment-platform.tree.json +22 -0
- package/skills/archify/examples/payment-processors.class.json +177 -0
- package/skills/archify/examples/payments.class.json +85 -0
- package/skills/archify/examples/product-analytics.dataflow.json +71 -0
- package/skills/archify/examples/production-deployment.architecture.json +51 -0
- package/skills/archify/examples/release-delivery.workflow.json +57 -0
- package/skills/archify/examples/subscription-billing.erd.json +548 -0
- package/skills/archify/examples/web-app.architecture.json +41 -0
- package/skills/archify/explainer/browser-check.mjs +208 -0
- package/skills/archify/explainer/charts.mjs +70 -0
- package/skills/archify/explainer/check.mjs +167 -0
- package/skills/archify/explainer/diagrams.mjs +517 -0
- package/skills/archify/explainer/diff.mjs +124 -0
- package/skills/archify/explainer/inline.mjs +155 -0
- package/skills/archify/explainer/layout.mjs +35 -0
- package/skills/archify/explainer/page-bundle.mjs +16 -0
- package/skills/archify/explainer/parse.mjs +300 -0
- package/skills/archify/explainer/patch.mjs +117 -0
- package/skills/archify/explainer/prepare.mjs +131 -0
- package/skills/archify/explainer/reader.css +472 -0
- package/skills/archify/explainer/reader.js +666 -0
- package/skills/archify/explainer/render.mjs +224 -0
- package/skills/archify/explainer/revision.mjs +20 -0
- package/skills/archify/explainer/sources.mjs +318 -0
- package/skills/archify/explainer/validate.mjs +138 -0
- package/skills/archify/explainer/vendor/pele/LICENSE +40 -0
- package/skills/archify/explainer/vendor/pele/README.md +5 -0
- package/skills/archify/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/skills/archify/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/skills/archify/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/skills/archify/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/skills/archify/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/skills/archify/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/skills/archify/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/skills/archify/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/skills/archify/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/skills/archify/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/skills/archify/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/skills/archify/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/skills/archify/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/skills/archify/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/skills/archify/explainer/vendor/pele/core.js +1 -0
- package/skills/archify/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/skills/archify/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/skills/archify/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/skills/archify/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/skills/archify/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/skills/archify/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/skills/archify/explainer/writing.mjs +139 -0
- package/skills/archify/locales/README.md +40 -0
- package/skills/archify/locales/ar.json +488 -0
- package/skills/archify/locales/en.json +488 -0
- package/skills/archify/locales/es.json +488 -0
- package/skills/archify/locales/ko.json +488 -0
- package/skills/archify/locales/manifest.json +10 -0
- package/skills/archify/locales/zh-CN.json +488 -0
- package/skills/archify/locales/zh-TW.json +488 -0
- package/skills/archify/migrations/workflow-v2.mjs +280 -0
- package/skills/archify/package.json +17 -0
- package/skills/archify/recipes/scenarios.mjs +455 -0
- package/skills/archify/references/architecture-layout-repair.md +18 -0
- package/skills/archify/references/authoring-contract.md +645 -0
- package/skills/archify/references/authoring-defaults.md +57 -0
- package/skills/archify/references/brand-marks.md +119 -0
- package/skills/archify/references/delivery-contract.md +665 -0
- package/skills/archify/references/explainer-authoring.md +274 -0
- package/skills/archify/references/repository-authoring.md +126 -0
- package/skills/archify/references/update-awareness.md +13 -0
- package/skills/archify/references/viewer-runtime.md +52 -0
- package/skills/archify/renderers/architecture/grid.mjs +62 -0
- package/skills/archify/renderers/architecture/labels.mjs +2 -0
- package/skills/archify/renderers/architecture/render-architecture.mjs +1204 -0
- package/skills/archify/renderers/architecture/routing.mjs +1469 -0
- package/skills/archify/renderers/class/README.md +130 -0
- package/skills/archify/renderers/class/render-class.mjs +1095 -0
- package/skills/archify/renderers/dataflow/README.md +176 -0
- package/skills/archify/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/skills/archify/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/skills/archify/renderers/dataflow/label-capacity.mjs +81 -0
- package/skills/archify/renderers/dataflow/render-dataflow.mjs +23 -0
- package/skills/archify/renderers/erd/README.md +246 -0
- package/skills/archify/renderers/erd/grid.mjs +150 -0
- package/skills/archify/renderers/erd/render-erd.mjs +1588 -0
- package/skills/archify/renderers/explainer/render-explainer.mjs +42 -0
- package/skills/archify/renderers/lifecycle/README.md +104 -0
- package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +1134 -0
- package/skills/archify/renderers/sequence/README.md +146 -0
- package/skills/archify/renderers/sequence/render-sequence.mjs +1105 -0
- package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
- package/skills/archify/renderers/shared/automatic-labels.mjs +290 -0
- package/skills/archify/renderers/shared/brand-marks.mjs +706 -0
- package/skills/archify/renderers/shared/cli.mjs +430 -0
- package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
- package/skills/archify/renderers/shared/diagnostics.mjs +0 -0
- package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/skills/archify/renderers/shared/generated-validators.mjs +279 -0
- package/skills/archify/renderers/shared/geometry.mjs +2073 -0
- package/skills/archify/renderers/shared/i18n.mjs +328 -0
- package/skills/archify/renderers/shared/layout-report.mjs +57 -0
- package/skills/archify/renderers/shared/legend.mjs +227 -0
- package/skills/archify/renderers/shared/node-card-style.mjs +32 -0
- package/skills/archify/renderers/shared/output-path.mjs +440 -0
- package/skills/archify/renderers/shared/owned-temp-directory.mjs +82 -0
- package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
- package/skills/archify/renderers/shared/portable-path.mjs +408 -0
- package/skills/archify/renderers/shared/repository-evidence.mjs +394 -0
- package/skills/archify/renderers/shared/repository-location.mjs +71 -0
- package/skills/archify/renderers/shared/route-quality.mjs +670 -0
- package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
- package/skills/archify/renderers/shared/software-notices.mjs +6 -0
- package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
- package/skills/archify/renderers/shared/svg-path-analysis.mjs +135 -0
- package/skills/archify/renderers/shared/text-fit.mjs +105 -0
- package/skills/archify/renderers/shared/utils.mjs +260 -0
- package/skills/archify/renderers/shared/validator.mjs +83 -0
- package/skills/archify/renderers/timeline/README.md +76 -0
- package/skills/archify/renderers/timeline/render-timeline.mjs +576 -0
- package/skills/archify/renderers/tree/README.md +96 -0
- package/skills/archify/renderers/tree/render-tree.mjs +573 -0
- package/skills/archify/renderers/waterfall/README.md +73 -0
- package/skills/archify/renderers/waterfall/render-waterfall.mjs +413 -0
- package/skills/archify/renderers/workflow/README.md +337 -0
- package/skills/archify/renderers/workflow/render-workflow.mjs +47 -0
- package/skills/archify/renderers/workflow/workflow-compiler.mjs +5920 -0
- package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +161 -0
- package/skills/archify/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/skills/archify/schemas/README.md +258 -0
- package/skills/archify/schemas/architecture.schema.json +152 -0
- package/skills/archify/schemas/class.schema.json +345 -0
- package/skills/archify/schemas/common.schema.json +181 -0
- package/skills/archify/schemas/dataflow.schema.json +254 -0
- package/skills/archify/schemas/erd.schema.json +381 -0
- package/skills/archify/schemas/explainer.schema.json +927 -0
- package/skills/archify/schemas/lifecycle.schema.json +179 -0
- package/skills/archify/schemas/sequence.schema.json +234 -0
- package/skills/archify/schemas/timeline.schema.json +160 -0
- package/skills/archify/schemas/tree.schema.json +141 -0
- package/skills/archify/schemas/waterfall.schema.json +147 -0
- package/skills/archify/schemas/workflow.schema.json +439 -0
- package/skills/archify/scripts/check-render-output.mjs +1469 -0
- package/skills/archify/scripts/check-update.mjs +1717 -0
- package/skills/archify/scripts/delivery-update-child.mjs +26 -0
- package/skills/archify/scripts/render-examples.mjs +36 -0
- package/skills/archify/scripts/update-contract.mjs +187 -0
- package/skills/archify/skill-release.json +10 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_LOCALE = 'en';
|
|
5
|
+
|
|
6
|
+
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
7
|
+
|
|
8
|
+
export function escapeHtml(value) {
|
|
9
|
+
return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Bundled catalogs are package data, not renderer code. locales/manifest.json
|
|
13
|
+
// enrolls the catalogs meta.locale selects automatically; only listed files
|
|
14
|
+
// are read, relative to this installed package and never from the working
|
|
15
|
+
// directory, home, environment, or network. Adding a language changes data
|
|
16
|
+
// only. en.json defines the canonical keys and the final fallback.
|
|
17
|
+
const LOCALES_DIR = new URL('../../locales/', import.meta.url);
|
|
18
|
+
// Mirrors common.schema.json#/$defs/locale.
|
|
19
|
+
const LOCALE_TAG = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
20
|
+
const CATALOG_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
|
21
|
+
|
|
22
|
+
function readLocaleData(file) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(fileURLToPath(new URL(file, LOCALES_DIR)), 'utf8'));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error(`Archify locale data ${JSON.stringify(file)} could not be loaded: ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadManifest() {
|
|
31
|
+
const catalogs = readLocaleData('manifest.json')?.catalogs;
|
|
32
|
+
if (!Array.isArray(catalogs)) throw new Error('Archify locale manifest must list catalogs');
|
|
33
|
+
const byKey = new Map();
|
|
34
|
+
for (const entry of catalogs) {
|
|
35
|
+
const { locale, file } = entry || {};
|
|
36
|
+
if (typeof locale !== 'string' || locale.length > 35 || !LOCALE_TAG.test(locale)) {
|
|
37
|
+
throw new Error(`Archify locale manifest has an invalid locale tag ${JSON.stringify(locale)}`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof file !== 'string' || !CATALOG_FILE.test(file)) {
|
|
40
|
+
throw new Error(`Archify locale manifest has an invalid catalog file for ${locale}`);
|
|
41
|
+
}
|
|
42
|
+
// Tags match case-insensitively, so two entries differing only in case
|
|
43
|
+
// would make selection ambiguous.
|
|
44
|
+
const key = locale.toLowerCase();
|
|
45
|
+
if (byKey.has(key)) throw new Error(`Archify locale manifest lists ${locale} more than once`);
|
|
46
|
+
byKey.set(key, { locale, file });
|
|
47
|
+
}
|
|
48
|
+
if (byKey.get(DEFAULT_LOCALE)?.locale !== DEFAULT_LOCALE) {
|
|
49
|
+
throw new Error(`Archify locale manifest must enroll the ${DEFAULT_LOCALE} source catalog`);
|
|
50
|
+
}
|
|
51
|
+
return byKey;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MANIFEST = loadManifest();
|
|
55
|
+
|
|
56
|
+
export const SUPPORTED_LOCALES = Object.freeze([...MANIFEST.values()].map(({ locale }) => locale));
|
|
57
|
+
|
|
58
|
+
const EN = Object.freeze(readLocaleData(MANIFEST.get(DEFAULT_LOCALE).file));
|
|
59
|
+
for (const [key, message] of Object.entries(EN)) {
|
|
60
|
+
if (typeof message !== 'string' || message.length === 0) {
|
|
61
|
+
throw new Error(`Archify source message ${JSON.stringify(key)} must be a non-empty string`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CANONICAL_KEYS = Object.keys(EN);
|
|
66
|
+
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
|
|
67
|
+
|
|
68
|
+
function extractPlaceholders(message) {
|
|
69
|
+
return new Set([...String(message).matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const CANONICAL_PLACEHOLDERS = Object.fromEntries(
|
|
73
|
+
CANONICAL_KEYS.map((key) => [key, extractPlaceholders(EN[key])]),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function placeholdersMatch(expected, actual) {
|
|
77
|
+
if (expected.size !== actual.size) return false;
|
|
78
|
+
for (const token of expected) if (!actual.has(token)) return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Splits translation data into the entries that can replace a canonical
|
|
83
|
+
// message and the ones that cannot. Rejected entries never replace a valid
|
|
84
|
+
// lower-priority message.
|
|
85
|
+
function classifyTranslations(translations) {
|
|
86
|
+
const supplied = Object.keys(translations || {});
|
|
87
|
+
const usable = new Map();
|
|
88
|
+
const unknownKeys = [];
|
|
89
|
+
const placeholderMismatches = [];
|
|
90
|
+
for (const key of supplied) {
|
|
91
|
+
if (!Object.hasOwn(CANONICAL_PLACEHOLDERS, key)) {
|
|
92
|
+
unknownKeys.push(key);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const value = translations[key];
|
|
96
|
+
const expected = [...CANONICAL_PLACEHOLDERS[key]].sort();
|
|
97
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
98
|
+
placeholderMismatches.push({ key, expected, actual: null });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const actual = extractPlaceholders(value);
|
|
102
|
+
if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], actual)) {
|
|
103
|
+
usable.set(key, value);
|
|
104
|
+
} else {
|
|
105
|
+
placeholderMismatches.push({ key, expected, actual: [...actual].sort() });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { supplied, usable, unknownKeys, placeholderMismatches };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Validates caller-supplied translation data against the canonical (English)
|
|
112
|
+
// message-key set. Pure and side-effect free; coverage here describes only the
|
|
113
|
+
// supplied data. resolveCatalog() reports the final resolved coverage.
|
|
114
|
+
export function validateTranslations(translations = {}) {
|
|
115
|
+
const { usable, unknownKeys, placeholderMismatches } = classifyTranslations(translations);
|
|
116
|
+
return {
|
|
117
|
+
totalKeys: CANONICAL_KEYS.length,
|
|
118
|
+
coveredKeys: usable.size,
|
|
119
|
+
coverage: CANONICAL_KEYS.length ? usable.size / CANONICAL_KEYS.length : 1,
|
|
120
|
+
missingKeys: CANONICAL_KEYS.filter((key) => !Object.hasOwn(translations || {}, key)),
|
|
121
|
+
unknownKeys,
|
|
122
|
+
placeholderMismatches,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function bundledLocaleFor(locale) {
|
|
127
|
+
if (typeof locale !== 'string') return null;
|
|
128
|
+
return MANIFEST.get(locale.toLowerCase())?.locale || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isEnglishTag(locale) {
|
|
132
|
+
const key = locale.toLowerCase();
|
|
133
|
+
return key === DEFAULT_LOCALE || key.startsWith(`${DEFAULT_LOCALE}-`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Bundled catalogs load on first use and stay immutable; a diagram's
|
|
137
|
+
// overrides are layered onto a copy.
|
|
138
|
+
const BUNDLED_CATALOGS = new Map([[DEFAULT_LOCALE, { usable: new Map(Object.entries(EN)), messages: EN }]]);
|
|
139
|
+
|
|
140
|
+
function bundledCatalog(locale) {
|
|
141
|
+
if (!BUNDLED_CATALOGS.has(locale)) {
|
|
142
|
+
const { usable } = classifyTranslations(readLocaleData(MANIFEST.get(locale.toLowerCase()).file));
|
|
143
|
+
const messages = Object.freeze(Object.fromEntries(
|
|
144
|
+
CANONICAL_KEYS.map((key) => [key, usable.get(key) ?? EN[key]]),
|
|
145
|
+
));
|
|
146
|
+
BUNDLED_CATALOGS.set(locale, { usable, messages });
|
|
147
|
+
}
|
|
148
|
+
return BUNDLED_CATALOGS.get(locale);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resolves each canonical message as: valid meta.translations value →
|
|
152
|
+
// selected bundled-catalog value → English source. Pure; returns the resolved
|
|
153
|
+
// catalog with a report that separates rejected overrides from real English
|
|
154
|
+
// fallback gaps. Omitted or empty translations mean no override. A tag with
|
|
155
|
+
// neither a bundled catalog nor a usable override falls back to English UI
|
|
156
|
+
// and language metadata.
|
|
157
|
+
export function resolveCatalog(locale, translations = undefined) {
|
|
158
|
+
const override = classifyTranslations(translations);
|
|
159
|
+
const bundledLocale = bundledLocaleFor(locale);
|
|
160
|
+
const fallback = Boolean(locale) && !bundledLocale && override.usable.size === 0;
|
|
161
|
+
const resolvedLocale = !locale || fallback ? DEFAULT_LOCALE : bundledLocale || locale;
|
|
162
|
+
const bundled = bundledLocale ? bundledCatalog(bundledLocale) : null;
|
|
163
|
+
const english = isEnglishTag(resolvedLocale);
|
|
164
|
+
const messages = {};
|
|
165
|
+
const fallbackKeys = [];
|
|
166
|
+
for (const key of CANONICAL_KEYS) {
|
|
167
|
+
if (override.usable.has(key)) {
|
|
168
|
+
messages[key] = override.usable.get(key);
|
|
169
|
+
} else if (bundled?.usable.has(key)) {
|
|
170
|
+
messages[key] = bundled.usable.get(key);
|
|
171
|
+
} else {
|
|
172
|
+
messages[key] = EN[key];
|
|
173
|
+
if (!english) fallbackKeys.push(key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const translatedKeys = english ? CANONICAL_KEYS.length : CANONICAL_KEYS.length - fallbackKeys.length;
|
|
177
|
+
return {
|
|
178
|
+
messages: Object.freeze(messages),
|
|
179
|
+
report: {
|
|
180
|
+
locale: locale || null,
|
|
181
|
+
resolvedLocale,
|
|
182
|
+
bundledLocale,
|
|
183
|
+
fallback,
|
|
184
|
+
totalKeys: CANONICAL_KEYS.length,
|
|
185
|
+
translatedKeys,
|
|
186
|
+
coverage: CANONICAL_KEYS.length ? translatedKeys / CANONICAL_KEYS.length : 1,
|
|
187
|
+
fallbackKeys,
|
|
188
|
+
override: {
|
|
189
|
+
suppliedKeys: override.supplied.length,
|
|
190
|
+
appliedKeys: override.usable.size,
|
|
191
|
+
unknownKeys: override.unknownKeys,
|
|
192
|
+
placeholderMismatches: override.placeholderMismatches,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Per-document catalogs installed by registerLocale(). Tags are
|
|
199
|
+
// case-insensitive, so the key is lowercased: renderers may pass the authored
|
|
200
|
+
// meta.locale (zh-cn) or its resolved tag (zh-CN) and reach the same document
|
|
201
|
+
// catalog. They never mutate a bundled catalog.
|
|
202
|
+
const DOCUMENT_CATALOGS = new Map();
|
|
203
|
+
|
|
204
|
+
function documentKey(locale) {
|
|
205
|
+
return typeof locale === 'string' ? locale.toLowerCase() : locale;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function catalogFor(locale) {
|
|
209
|
+
const key = documentKey(locale);
|
|
210
|
+
if (DOCUMENT_CATALOGS.has(key)) return DOCUMENT_CATALOGS.get(key);
|
|
211
|
+
const bundledLocale = bundledLocaleFor(locale);
|
|
212
|
+
return bundledLocale ? { locale: bundledLocale, messages: bundledCatalog(bundledLocale).messages } : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Installs the resolved catalog for one authored locale and returns its
|
|
216
|
+
// report. Re-registering the same tag replaces the previous document's
|
|
217
|
+
// overrides; a fallback clears them.
|
|
218
|
+
export function registerLocale(locale, translations = undefined) {
|
|
219
|
+
const { messages, report } = resolveCatalog(locale, translations);
|
|
220
|
+
if (report.fallback || !locale) {
|
|
221
|
+
DOCUMENT_CATALOGS.delete(documentKey(locale));
|
|
222
|
+
} else {
|
|
223
|
+
DOCUMENT_CATALOGS.set(documentKey(locale), { locale: report.resolvedLocale, messages });
|
|
224
|
+
}
|
|
225
|
+
return report;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Agent-facing warnings for one document's meta.locale/meta.translations.
|
|
229
|
+
// Pure, so the renderer and the CLI receipts (validate/deliver/finalize)
|
|
230
|
+
// report the same diagnostics from the same input.
|
|
231
|
+
export function localeDiagnostics(diagramType, meta) {
|
|
232
|
+
const locale = meta?.locale;
|
|
233
|
+
if (!locale) return [];
|
|
234
|
+
const report = resolveCatalog(locale, meta.translations).report;
|
|
235
|
+
const diagnostics = [];
|
|
236
|
+
const { unknownKeys, placeholderMismatches, appliedKeys } = report.override;
|
|
237
|
+
const rejected = unknownKeys.length + placeholderMismatches.length;
|
|
238
|
+
if (rejected) {
|
|
239
|
+
const kept = report.bundledLocale ? `bundled ${report.bundledLocale} message` : 'English message';
|
|
240
|
+
diagnostics.push({
|
|
241
|
+
code: 'i18n/invalid-translation',
|
|
242
|
+
severity: 'warning',
|
|
243
|
+
message: `meta.translations for locale ${JSON.stringify(locale)} has ${rejected} unusable ${rejected === 1 ? 'entry' : 'entries'} (${unknownKeys.length} unknown, ${placeholderMismatches.length} placeholder mismatch); each keeps its ${kept}.`,
|
|
244
|
+
subject: { diagramType, path: '/meta/translations' },
|
|
245
|
+
evidence: {
|
|
246
|
+
locale,
|
|
247
|
+
unknownKeys: unknownKeys.slice(0, 10),
|
|
248
|
+
unknownKeysTotal: unknownKeys.length,
|
|
249
|
+
placeholderMismatches: placeholderMismatches.slice(0, 10),
|
|
250
|
+
placeholderMismatchesTotal: placeholderMismatches.length,
|
|
251
|
+
},
|
|
252
|
+
supportedFixes: ['Remove each unknown key or rename it to a canonical message key.', 'Use exactly the {placeholders} of the English source string for each mismatched key.'],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (report.fallback) {
|
|
256
|
+
diagnostics.push({
|
|
257
|
+
code: 'i18n/locale-fallback',
|
|
258
|
+
severity: 'warning',
|
|
259
|
+
message: `meta.locale ${JSON.stringify(locale)} has no bundled catalog and no usable meta.translations; the Viewer chrome and <html lang> fall back to English.`,
|
|
260
|
+
subject: { diagramType, path: '/meta/locale' },
|
|
261
|
+
evidence: { locale, bundledLocales: SUPPORTED_LOCALES },
|
|
262
|
+
supportedFixes: ['Supply meta.translations for this locale.', `Use a bundled locale: ${SUPPORTED_LOCALES.join(', ')}.`],
|
|
263
|
+
});
|
|
264
|
+
} else if (report.fallbackKeys.length) {
|
|
265
|
+
const source = [
|
|
266
|
+
report.bundledLocale && `the bundled ${report.bundledLocale} catalog`,
|
|
267
|
+
appliedKeys && `${appliedKeys} meta.translations ${appliedKeys === 1 ? 'entry' : 'entries'}`,
|
|
268
|
+
].filter(Boolean).join(' and ');
|
|
269
|
+
const missingKeys = report.fallbackKeys.slice(0, 10);
|
|
270
|
+
diagnostics.push({
|
|
271
|
+
code: 'i18n/translation-coverage',
|
|
272
|
+
severity: 'warning',
|
|
273
|
+
message: `meta.locale ${JSON.stringify(locale)} resolves ${report.translatedKeys}/${report.totalKeys} renderer-owned messages (${Math.floor(report.coverage * 100)}%) from ${source}; ${report.fallbackKeys.length} fall back to English.`,
|
|
274
|
+
subject: { diagramType, path: '/meta/translations' },
|
|
275
|
+
evidence: {
|
|
276
|
+
locale,
|
|
277
|
+
resolvedLocale: report.resolvedLocale,
|
|
278
|
+
bundledLocale: report.bundledLocale,
|
|
279
|
+
translatedKeys: report.translatedKeys,
|
|
280
|
+
totalKeys: report.totalKeys,
|
|
281
|
+
appliedOverrideKeys: appliedKeys,
|
|
282
|
+
missingKeys,
|
|
283
|
+
missingKeysTotal: report.fallbackKeys.length,
|
|
284
|
+
// English source for the listed gaps, so a caller can translate
|
|
285
|
+
// exactly those keys without re-reading the whole catalog.
|
|
286
|
+
englishSource: Object.fromEntries(missingKeys.map((key) => [key, EN[key]])),
|
|
287
|
+
},
|
|
288
|
+
supportedFixes: ['Add each missing key to meta.translations, translating its English source string and keeping its {placeholders}.'],
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return diagnostics;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function resolveLocale(locale) {
|
|
295
|
+
return catalogFor(locale)?.locale || DEFAULT_LOCALE;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function formatMessage(template, values = {}) {
|
|
299
|
+
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => (
|
|
300
|
+
Object.hasOwn(values, key) ? String(values[key]) : match
|
|
301
|
+
));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function translateMessage(locale, key, values = {}) {
|
|
305
|
+
const catalog = catalogFor(locale)?.messages || EN;
|
|
306
|
+
if (!Object.hasOwn(catalog, key)) {
|
|
307
|
+
throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolveLocale(locale)}`);
|
|
308
|
+
}
|
|
309
|
+
return formatMessage(catalog[key], values);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function translateCount(locale, key, count, values = {}) {
|
|
313
|
+
const suffix = count === 1 ? 'one' : 'other';
|
|
314
|
+
return translateMessage(locale, `${key}.${suffix}`, { ...values, count });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function viewerCatalog(locale) {
|
|
318
|
+
const messages = catalogFor(locale)?.messages || EN;
|
|
319
|
+
return Object.fromEntries(Object.entries(messages).filter(([key]) => key.startsWith('viewer.')));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function localizeTemplate(template, locale) {
|
|
323
|
+
return template.replace(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g, (_match, key) => escapeHtml(translateMessage(locale, key)));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function catalogKeys() {
|
|
327
|
+
return [...CANONICAL_KEYS];
|
|
328
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Serialize computed layout for dry-run / inspect (#9). */
|
|
2
|
+
|
|
3
|
+
export function componentBox(c) {
|
|
4
|
+
return {
|
|
5
|
+
id: c.id,
|
|
6
|
+
type: c.type,
|
|
7
|
+
label: c.label,
|
|
8
|
+
x: Math.round(c.x),
|
|
9
|
+
y: Math.round(c.y),
|
|
10
|
+
width: c.width,
|
|
11
|
+
height: c.height,
|
|
12
|
+
...(Number.isInteger(c.row) ? { row: c.row } : {}),
|
|
13
|
+
...(Number.isInteger(c.col) ? { col: c.col } : {}),
|
|
14
|
+
...(Array.isArray(c.pos) ? { pos: c.pos.map(Math.round) } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function entityBox(e) {
|
|
19
|
+
return {
|
|
20
|
+
id: e.id,
|
|
21
|
+
label: e.label,
|
|
22
|
+
x: Math.round(e.x),
|
|
23
|
+
y: Math.round(e.y),
|
|
24
|
+
width: e.width,
|
|
25
|
+
height: e.height,
|
|
26
|
+
attributes: (Array.isArray(e.attributes) ? e.attributes : []).map((attribute) => attribute.name),
|
|
27
|
+
...(Number.isInteger(e.row) ? { row: e.row } : {}),
|
|
28
|
+
...(Number.isInteger(e.col) ? { col: e.col } : {}),
|
|
29
|
+
...(Array.isArray(e.pos) ? { pos: e.pos.map(Math.round) } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function boundaryBox(b) {
|
|
34
|
+
return {
|
|
35
|
+
kind: b.kind,
|
|
36
|
+
label: b.label,
|
|
37
|
+
x: Math.round(b.x),
|
|
38
|
+
y: Math.round(b.y),
|
|
39
|
+
width: Math.round(b.width),
|
|
40
|
+
height: Math.round(b.height),
|
|
41
|
+
wraps: b.wraps,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function connectionPath(conn, routed, labelAt) {
|
|
46
|
+
return {
|
|
47
|
+
from: conn.from,
|
|
48
|
+
to: conn.to,
|
|
49
|
+
label: conn.label ?? null,
|
|
50
|
+
variant: conn.variant ?? 'default',
|
|
51
|
+
route: conn.route ?? 'auto',
|
|
52
|
+
// These points are repair inputs: rounding a fractional port makes a
|
|
53
|
+
// reused waypoint diagonal relative to the actual endpoint.
|
|
54
|
+
points: routed.points.map(([x, y]) => [x, y]),
|
|
55
|
+
...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { throwDiagnosticError } from './diagnostics.mjs';
|
|
2
|
+
import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
|
|
3
|
+
import { esc, textUnits } from './utils.mjs';
|
|
4
|
+
import { translateMessage } from './i18n.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_FONT_SIZE = 8;
|
|
7
|
+
const DEFAULT_ITEM_GAP = 22;
|
|
8
|
+
const DEFAULT_LINE_GAP = 22;
|
|
9
|
+
const DEFAULT_SWATCH_GAP = 8;
|
|
10
|
+
const TEXT_ADVANCE_EM = 0.62;
|
|
11
|
+
const INTERACTIVE_BADGE_ALLOWANCE = 21;
|
|
12
|
+
|
|
13
|
+
export function relationshipLegendObstacles(relations, { pointsFor, labelRectFor } = {}) {
|
|
14
|
+
const obstacles = [];
|
|
15
|
+
for (const [index, relation] of (Array.isArray(relations) ? relations : []).entries()) {
|
|
16
|
+
const points = typeof pointsFor === 'function' ? pointsFor(relation, index) : [];
|
|
17
|
+
const finitePoints = (Array.isArray(points) ? points : []).filter((point) => (
|
|
18
|
+
Array.isArray(point) && point.length === 2 && point.every(Number.isFinite)
|
|
19
|
+
));
|
|
20
|
+
for (let pointIndex = 0; pointIndex < finitePoints.length - 1; pointIndex += 1) {
|
|
21
|
+
obstacles.push({
|
|
22
|
+
kind: 'relationship-segment',
|
|
23
|
+
start: finitePoints[pointIndex],
|
|
24
|
+
end: finitePoints[pointIndex + 1],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const labelRect = typeof labelRectFor === 'function' ? labelRectFor(relation, index) : null;
|
|
28
|
+
if (labelRect && [labelRect.x, labelRect.y, labelRect.width, labelRect.height].every(Number.isFinite)) {
|
|
29
|
+
obstacles.push({ kind: 'relationship-label', ...labelRect });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return obstacles;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveLegend(config, catalog, presentKinds) {
|
|
36
|
+
const mode = config?.mode || 'auto';
|
|
37
|
+
if (mode === 'hidden') return [];
|
|
38
|
+
const present = presentKinds instanceof Set ? presentKinds : new Set(presentKinds || []);
|
|
39
|
+
const overrides = config?.entries || {};
|
|
40
|
+
|
|
41
|
+
return catalog.flatMap((catalogEntry) => {
|
|
42
|
+
const override = overrides[catalogEntry.kind] || {};
|
|
43
|
+
const selectedByMode = mode === 'all' || present.has(catalogEntry.kind);
|
|
44
|
+
const visible = override.visible === true || (selectedByMode && override.visible !== false);
|
|
45
|
+
if (!visible) return [];
|
|
46
|
+
return [{
|
|
47
|
+
...catalogEntry,
|
|
48
|
+
label: override.label || catalogEntry.label,
|
|
49
|
+
present: present.has(catalogEntry.kind),
|
|
50
|
+
interactive: catalogEntry.interactive !== false && present.has(catalogEntry.kind),
|
|
51
|
+
}];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Labels render slightly larger than the layout font size; measure the
|
|
56
|
+
// rendered size so a long label cannot run into the next entry.
|
|
57
|
+
function renderedLegendFontSize(fontSize) {
|
|
58
|
+
return fontSize < 8 ? fontSize + 0.5 : fontSize + 2;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function measuredEntryWidth(entry, fontSize, swatchGap) {
|
|
62
|
+
const swatchWidth = entry.swatchWidth ?? 14;
|
|
63
|
+
return Math.ceil(
|
|
64
|
+
swatchWidth
|
|
65
|
+
+ swatchGap
|
|
66
|
+
+ textUnits(entry.label) * renderedLegendFontSize(fontSize) * TEXT_ADVANCE_EM
|
|
67
|
+
+ (entry.interactive ? INTERACTIVE_BADGE_ALLOWANCE : 0)
|
|
68
|
+
+ (entry.trailingWidth ?? 0),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One pure footprint calculation owns both auto-viewBox sizing and final SVG
|
|
73
|
+
// placement. Callers must not maintain a second approximation of legend width
|
|
74
|
+
// or row count; that would make generated geometry disagree with validation.
|
|
75
|
+
export function legendFootprint(entries, {
|
|
76
|
+
width,
|
|
77
|
+
fontSize = DEFAULT_FONT_SIZE,
|
|
78
|
+
itemGap = DEFAULT_ITEM_GAP,
|
|
79
|
+
lineGap = DEFAULT_LINE_GAP,
|
|
80
|
+
swatchGap = DEFAULT_SWATCH_GAP,
|
|
81
|
+
} = {}) {
|
|
82
|
+
if (!entries.length) {
|
|
83
|
+
return { measured: [], rows: [], rowCount: 0, minWidth: 0, extraHeight: 0 };
|
|
84
|
+
}
|
|
85
|
+
const measured = entries.map((entry) => ({
|
|
86
|
+
...entry,
|
|
87
|
+
width: measuredEntryWidth(entry, fontSize, entry.swatchGap ?? swatchGap),
|
|
88
|
+
}));
|
|
89
|
+
const rows = [[]];
|
|
90
|
+
let cursor = 0;
|
|
91
|
+
for (const entry of measured) {
|
|
92
|
+
const row = rows.at(-1);
|
|
93
|
+
const required = (row.length ? itemGap : 0) + entry.width;
|
|
94
|
+
if (row.length && cursor + required > width) {
|
|
95
|
+
rows.push([entry]);
|
|
96
|
+
cursor = entry.width;
|
|
97
|
+
} else {
|
|
98
|
+
row.push(entry);
|
|
99
|
+
cursor += required;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
measured,
|
|
104
|
+
rows,
|
|
105
|
+
rowCount: rows.length,
|
|
106
|
+
minWidth: measured.reduce((width, entry) => Math.max(width, entry.width), 0),
|
|
107
|
+
extraHeight: (rows.length - 1) * lineGap,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function measureLegend(entries, {
|
|
112
|
+
x,
|
|
113
|
+
baselineY,
|
|
114
|
+
width,
|
|
115
|
+
fontSize = DEFAULT_FONT_SIZE,
|
|
116
|
+
itemGap = DEFAULT_ITEM_GAP,
|
|
117
|
+
lineGap = DEFAULT_LINE_GAP,
|
|
118
|
+
swatchGap = DEFAULT_SWATCH_GAP,
|
|
119
|
+
minTitleY = 0,
|
|
120
|
+
obstacles = [],
|
|
121
|
+
unfit = 'error',
|
|
122
|
+
diagramType = 'diagram',
|
|
123
|
+
} = {}) {
|
|
124
|
+
if (!entries.length) return { entries: [], rowCount: 0, titleY: null };
|
|
125
|
+
const footprint = legendFootprint(entries, { width, fontSize, itemGap, lineGap, swatchGap });
|
|
126
|
+
const tooWide = footprint.measured.find((entry) => entry.width > width);
|
|
127
|
+
if (tooWide) {
|
|
128
|
+
if (unfit === 'hide') return null;
|
|
129
|
+
const message = `[legend/label-too-wide] ${diagramType} legend label for "${tooWide.kind}" needs ${tooWide.width}px but only ${width}px is available.`;
|
|
130
|
+
throwDiagnosticError(message, [{
|
|
131
|
+
code: 'legend/label-too-wide',
|
|
132
|
+
severity: 'error',
|
|
133
|
+
message,
|
|
134
|
+
subject: { diagramType, path: `/meta/legend/entries/${tooWide.kind}/label` },
|
|
135
|
+
evidence: { kind: tooWide.kind, measuredWidthPx: tooWide.width, availableWidthPx: width },
|
|
136
|
+
supportedFixes: ['shorten the legend label or use a wider viewBox'],
|
|
137
|
+
}]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const titleY = baselineY - footprint.extraHeight - 20;
|
|
141
|
+
const legendTopY = titleY - 10;
|
|
142
|
+
if (legendTopY < minTitleY) {
|
|
143
|
+
if (unfit === 'hide') return null;
|
|
144
|
+
const message = `[legend/vertical-overflow] ${diagramType} legend needs ${footprint.rowCount} rows, which would start at y=${legendTopY} above the available legend band at y=${minTitleY}.`;
|
|
145
|
+
throwDiagnosticError(message, [{
|
|
146
|
+
code: 'legend/vertical-overflow',
|
|
147
|
+
severity: 'error',
|
|
148
|
+
message,
|
|
149
|
+
subject: { diagramType, path: '/meta/legend' },
|
|
150
|
+
evidence: { rowCount: footprint.rowCount, requiredTopY: legendTopY, availableTopY: minTitleY },
|
|
151
|
+
supportedFixes: ['shorten legend labels, hide nonessential entries, or use a wider viewBox'],
|
|
152
|
+
}]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const positioned = [];
|
|
156
|
+
footprint.rows.forEach((row, rowIndex) => {
|
|
157
|
+
let entryX = x;
|
|
158
|
+
const baseline = baselineY - (footprint.rowCount - rowIndex - 1) * lineGap;
|
|
159
|
+
for (const entry of row) {
|
|
160
|
+
positioned.push({ ...entry, x: entryX, baseline, row: rowIndex });
|
|
161
|
+
entryX += entry.width + itemGap;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const legendRects = [
|
|
166
|
+
{ kind: 'title', x, y: legendTopY, width: 48, height: 14 },
|
|
167
|
+
...positioned.map((entry) => ({
|
|
168
|
+
kind: entry.kind,
|
|
169
|
+
x: entry.x,
|
|
170
|
+
y: entry.baseline - 10,
|
|
171
|
+
width: entry.width,
|
|
172
|
+
height: 14,
|
|
173
|
+
})),
|
|
174
|
+
];
|
|
175
|
+
const collision = legendRects.find((legendRect) => obstacles.some((obstacle) => (
|
|
176
|
+
Array.isArray(obstacle.start) && Array.isArray(obstacle.end)
|
|
177
|
+
? segmentIntersectsRect({ start: obstacle.start, end: obstacle.end }, legendRect)
|
|
178
|
+
: rectsOverlap(obstacle, legendRect)
|
|
179
|
+
)));
|
|
180
|
+
if (collision) {
|
|
181
|
+
if (unfit === 'hide') return null;
|
|
182
|
+
const message = `[legend/content-overlap] ${diagramType} legend entry "${collision.kind}" overlaps authored relationship geometry.`;
|
|
183
|
+
throwDiagnosticError(message, [{
|
|
184
|
+
code: 'legend/content-overlap',
|
|
185
|
+
severity: 'error',
|
|
186
|
+
message,
|
|
187
|
+
subject: { diagramType, path: '/meta/legend' },
|
|
188
|
+
evidence: { legendKind: collision.kind, legendRect: collision },
|
|
189
|
+
supportedFixes: ['shorten or hide legend entries, use a wider viewBox, or move the authored relationship route/label out of the legend band'],
|
|
190
|
+
}]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
entries: positioned,
|
|
195
|
+
rowCount: footprint.rowCount,
|
|
196
|
+
titleY,
|
|
197
|
+
fontSize,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `labelClass`/`labelWeight` let a renderer state that its legend labels are
|
|
202
|
+
// the same ink and weight as the value they describe; both default to the
|
|
203
|
+
// shared presentation, so callers that pass nothing keep their exact bytes.
|
|
204
|
+
export function renderLegend({ entries, layout, renderSwatch, locale, labelClass = 't-muted', labelWeight = 500 }) {
|
|
205
|
+
if (!entries.length) return '';
|
|
206
|
+
const measured = measureLegend(entries, layout);
|
|
207
|
+
if (!measured) return '';
|
|
208
|
+
const hasInteractiveEntries = measured.entries.some((entry) => entry.interactive);
|
|
209
|
+
const renderedFontSize = renderedLegendFontSize(measured.fontSize);
|
|
210
|
+
const rootAttributes = hasInteractiveEntries ? ' data-legend="" data-legend-bridge=""' : ' data-legend=""';
|
|
211
|
+
const parts = [
|
|
212
|
+
` <g${rootAttributes}>`,
|
|
213
|
+
` <text x="${layout.x}" y="${measured.titleY}" class="t-primary" font-size="12" font-weight="650">${esc(translateMessage(locale, 'legend.title'))}</text>`,
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
for (const entry of measured.entries) {
|
|
217
|
+
const interactive = entry.interactive
|
|
218
|
+
? ` data-legend-kind="${esc(entry.kind)}" data-legend-label="${esc(entry.label)}"`
|
|
219
|
+
: '';
|
|
220
|
+
parts.push(` <g data-legend-semantic-kind="${esc(entry.kind)}"${interactive} data-legend-x="${entry.x}" data-legend-baseline="${entry.baseline}" data-legend-width="${entry.width}">`);
|
|
221
|
+
parts.push(` ${renderSwatch(entry)}`);
|
|
222
|
+
parts.push(` <text x="${entry.x + (entry.swatchWidth ?? 14) + (entry.swatchGap ?? DEFAULT_SWATCH_GAP)}" y="${entry.baseline}" class="${labelClass}" font-size="${renderedFontSize}" font-weight="${labelWeight}">${esc(entry.label)}</text>`);
|
|
223
|
+
parts.push(' </g>');
|
|
224
|
+
}
|
|
225
|
+
parts.push(' </g>');
|
|
226
|
+
return parts.join('\n');
|
|
227
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Shared card styling for the architecture and workflow renderers. Every
|
|
2
|
+
// preset gets the cleaner cards and label chips; blueprint keeps its drafting
|
|
3
|
+
// dash patterns and square corners.
|
|
4
|
+
const TONES = ['frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external'];
|
|
5
|
+
|
|
6
|
+
export const NODE_CARD_ATTR = 'data-node-ui=""';
|
|
7
|
+
|
|
8
|
+
export function nodeCardStyle() {
|
|
9
|
+
const all = 'svg[data-node-ui]';
|
|
10
|
+
const solid = `${all}:is([data-preset="classic"], [data-preset="signal-flow"], [data-preset="editorial"])`;
|
|
11
|
+
const blueprint = `${all}[data-preset="blueprint"]`;
|
|
12
|
+
const editorial = `${all}[data-preset="editorial"]`;
|
|
13
|
+
const dark = `:is([data-theme="dark"] ${all}, ${all}[data-theme="dark"])`;
|
|
14
|
+
const node = '[data-node-id] > rect:not(.c-mask)';
|
|
15
|
+
const frameLabel = '[data-graph-role="structural-frame-label-mask"]';
|
|
16
|
+
const edgeLabel = 'g[data-edge-from] > rect.c-mask';
|
|
17
|
+
return ` <style>
|
|
18
|
+
${all} ${node} { stroke-width: 1.25px; }
|
|
19
|
+
${TONES.map((tone) => ` ${dark} ${node}.c-${tone} { fill: color-mix(in srgb, var(--${tone}-stroke) 13%, var(--mask)); stroke: color-mix(in srgb, var(--${tone}-stroke) 72%, var(--mask)); }`).join('\n')}
|
|
20
|
+
${solid} .c-region { fill: color-mix(in srgb, var(--cloud-stroke) 3%, transparent); stroke: color-mix(in srgb, var(--cloud-stroke) 42%, var(--lane-stroke)); stroke-dasharray: none; }
|
|
21
|
+
${all} .c-security-group { stroke: color-mix(in srgb, var(--security-stroke) 58%, var(--lane-stroke)); }
|
|
22
|
+
${solid} .c-security-group { stroke-dasharray: 4 3; }
|
|
23
|
+
${solid} .c-lane { stroke: var(--lane-stroke); stroke-dasharray: none; }
|
|
24
|
+
${all} ${frameLabel} { rx: 5px; fill: color-mix(in srgb, var(--cloud-stroke) 10%, var(--mask)); stroke: color-mix(in srgb, var(--cloud-stroke) 38%, var(--mask)); stroke-width: .8px; }
|
|
25
|
+
${all} [data-composition-frame-kind="security-group"] > ${frameLabel} { fill: color-mix(in srgb, var(--security-stroke) 9%, var(--mask)); stroke: color-mix(in srgb, var(--security-stroke) 36%, var(--mask)); }
|
|
26
|
+
${all} .phase-rule { opacity: .5; stroke-dasharray: none; }
|
|
27
|
+
${all} .phase-chip { stroke: var(--lane-stroke); stroke-width: .75px; }
|
|
28
|
+
${all} ${edgeLabel} { rx: 7px; stroke: var(--lane-stroke); stroke-width: .75px; }
|
|
29
|
+
${blueprint} :is(${frameLabel}, ${edgeLabel}, .phase-chip) { rx: 2px; }
|
|
30
|
+
${editorial} :is(${frameLabel}, ${edgeLabel}, .phase-chip) { rx: 3px; }
|
|
31
|
+
</style>`;
|
|
32
|
+
}
|