utils.mjs
260 lines · archify/renderers/shared/utils.mjs at a406594
1import {
2 escapeHtml as esc,
3 localizeTemplate,
4 resolveLocale,
5 translateMessage,
6 viewerCatalog,
7} from './i18n.mjs';
9export { esc };
11// `extra` appends type-owned definitions (the entity renderer's cardinality
12// markers) inside the same <defs>. Callers that pass nothing keep the exact
13// bytes the five longer-standing renderers already emit.
14export function renderDefinitions(extra = '') {
15 return ` <!-- Definitions -->
16 <defs>
17 <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
18 <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
19 </marker>
20 <marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
21 <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
22 </marker>
23 <marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
24 <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
25 </marker>
26 <marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
27 <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
28 </marker>
29 <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
30 <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
31 </pattern>${extra}
32 </defs>`;
33}
35const SIGIL_TONE = {
36 frontend: 'frontend',
37 start: 'frontend',
38 backend: 'backend',
39 active: 'frontend',
40 database: 'database',
41 success: 'backend',
42 cloud: 'cloud',
43 waiting: 'cloud',
44 security: 'security',
45 decision: 'database',
46 failure: 'security',
47 messagebus: 'messagebus',
48 external: 'external',
49 neutral: 'external',
50};
52const SIGIL_SHAPE = {
53 calendar: `<rect x="2" y="3.5" width="12" height="10.5" rx="2"/><path d="M5 2v3M11 2v3M2 7h12M5 10h2M9 10h2"/>`,
54 clock: `<circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/>`,
55 person: `<circle cx="8" cy="4.5" r="2.5"/><path d="M3 14v-2a5 5 0 0 1 10 0v2"/>`,
56 briefcase: `<rect x="2" y="5" width="12" height="9" rx="2"/><path d="M5 5V2h6v3M2 9h12M7 9v2h2V9"/>`,
57 flag: `<path d="M3 14V2h10l-2 3 2 3H3"/>`,
58 moon: `<path d="M13.5 10A6 6 0 0 1 6 2.5 6 6 0 1 0 13.5 10Z"/>`,
59 frontend: `<rect x="2" y="3" width="12" height="10" rx="2"/>
60 <path d="M2 6.5h12"/>
61 <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
62 <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
63 backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
64 database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
65 <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>`,
66 cloud: `<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>`,
67 security: `<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
68 <path d="m5.8 8 1.5 1.5 3-3"/>`,
69 messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
70 <circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
71 <circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
72 <circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
73 external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
74 <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
75 start: `<circle cx="8" cy="8" r="5"/>
76 <path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
77 active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
78 waiting: `<path d="M4 2.5h8M4 13.5h8M5 3c0 2.8 2 3.2 3 5-1 1.8-3 2.2-3 5M11 3c0 2.8-2 3.2-3 5 1 1.8 3 2.2 3 5"/>`,
79 decision: `<path d="M8 2.2 13.8 8 8 13.8 2.2 8Z"/>`,
80 success: `<circle cx="8" cy="8" r="5.3"/>
81 <path d="m5.2 8 1.8 1.8 3.8-4"/>`,
82 failure: `<circle cx="8" cy="8" r="5.3"/>
83 <path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
84 stop: `<circle cx="8" cy="8" r="5.3"/>
85 <rect x="6.1" y="6.1" width="3.8" height="3.8" rx=".5" class="sigil-fill"/>`,
86 neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
87 <circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
88};
90// A quiet, renderer-owned corner symbol (type default or authored icon). It is
91// SVG content rather than a
92// viewer overlay, so it survives canonical export while adding no focus target,
93// accessible name, layout box, or interaction state of its own.
94// Shared with label clearance so the reserved rail matches the actual icon.
95export const SEMANTIC_SIGIL_INSET = 6;
96export const SEMANTIC_SIGIL_SIZE = 11;
97export const SEMANTIC_SIGIL_FOOTPRINT = SEMANTIC_SIGIL_INSET + SEMANTIC_SIGIL_SIZE;
98// The viewer installs a runtime "sources" beacon on the node's top-right
99// rail, just left of the brand mark. Layout must reserve the same footprint
100// so labels never sit under the badge.
101export const SOURCE_BADGE_FOOTPRINT = 38;
103export function renderSemanticSigil(kind, { x, y, size = SEMANTIC_SIGIL_SIZE, icon } = {}) {
104 if (icon === 'none') return '';
105 const selected = icon ?? kind;
106 const normalized = Object.hasOwn(SIGIL_SHAPE, selected) ? selected : 'neutral';
107 const tone = SIGIL_TONE[kind] || 'external';
108 const scale = size / 16;
109 return `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
110 ${SIGIL_SHAPE[normalized]}
111 </g>`;
112}
114export function renderCards(cards) {
115 const list = Array.isArray(cards) ? cards : [];
116 return ` <!-- Info Cards -->
117 <div class="cards">
118${list.map((card) => ` <div class="card">
119 <div class="card-header">
120 <div class="card-dot ${esc(card.dot)}"></div>
121 <h3>${esc(card.title)}</h3>
122 </div>
123 <ul>
124${card.items.map((item) => ` <li>${esc(item)}</li>`).join('\n')}
125 </ul>
126 </div>`).join('\n\n')}
127 </div>`;
128}
130const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
131const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
132const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
133const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
134const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
136function serializeScriptJson(value) {
137 return JSON.stringify(value)
138 .replaceAll('<', '\\u003c')
139 .replaceAll('>', '\\u003e')
140 .replaceAll('&', '\\u0026');
141}
143const TEMPLATE_PLACEHOLDERS = [
144 '<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
145 '<title>[PROJECT NAME] Architecture Diagram</title>',
146 '<h1>[PROJECT NAME] Architecture</h1>',
147 I18N_PLACEHOLDER,
148];
150export function applyTemplate(template, {
151 title,
152 subtitle,
153 svg,
154 cards,
155 locale,
156 visualPreset = 'classic',
157 sourceEvidence = null,
158}) {
159 if (!SVG_SLOT_RE.test(template)) {
160 throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
161 }
162 if (!CARDS_SLOT_RE.test(template)) {
163 throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
164 }
165 if (!SUBTITLE_SLOT_RE.test(template)) {
166 throw new Error('applyTemplate: template missing subtitle placeholder');
167 }
168 for (const ph of TEMPLATE_PLACEHOLDERS) {
169 if (!template.includes(ph)) {
170 throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
171 }
172 }
173 // Keep existing custom templates compatible when evidence is not requested.
174 // Silently dropping verified evidence would be misleading, so the new slot
175 // becomes mandatory only for the opt-in evidence path.
176 if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
177 throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
178 }
179 // Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
180 // or rendered SVG must not be interpreted as a replacement pattern.
181 const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
182 const resolvedLocale = resolveLocale(locale);
183 const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
184 const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
185 ? `<p class="subtitle">${esc(subtitle)}</p>`
186 : '';
187 const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
188 return localizeTemplate(template, resolvedLocale)
189 .replace(I18N_PLACEHOLDER, () => i18nData)
190 .replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
191 .replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
192 .replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
193 .replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
194 ? `${indent}${renderedSubtitle}${newline}`
195 : '')
196 .replace(SVG_SLOT_RE, () => svg)
197 .replace(CARDS_SLOT_RE, () => cards)
198 .replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
199 ? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
200 : '');
201}
203// CJK and other wide/fullwidth glyphs render at roughly twice the advance
204// width of ASCII in the monospace stacks the template uses. Keep halfwidth
205// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
206// also cover vertical punctuation and supplementary East Asian scripts that
207// literal glyph ranges made difficult to audit.
208// Code points that take two columns of advance width: East Asian Wide and
209// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
210// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
211// render at the same square advance as the supplementary-plane emoji already
212// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
213// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
214// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
215// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
216// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
217// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
218// ranges because V8 has no \p{East_Asian_Width=W} property escape.
219const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
221// Variation selectors contribute no separate unit. This is a conservative
222// width estimate, not a measurement of the selected glyph: its actual advance
223// depends on the font and presentation (Unicode UAX #11).
224// VS16 requests emoji presentation, so reserve two units for the sequence.
225// VS15 retains the base's width estimate; forcing every text-presentation
226// sequence to one unit undercounts wide bases, including CJK characters whose
227// font ignores that selector. Neutral bases remain one unit. Some selected
228// text glyphs can be narrower than this estimate; prefer extra space to overflow.
229const VARIATION_SELECTOR_FIRST = 0xfe00;
230const VARIATION_SELECTOR_LAST = 0xfe0f;
231const VARIATION_SELECTOR_EMOJI = 0xfe0f;
233// Width measurement is pure and the same labels are measured many times per
234// compile, so the unit count is memoized by its input string.
235const TEXT_UNITS_CACHE = new Map();
236const MAX_TEXT_UNITS_CACHE_ENTRIES = 4096;
237const MAX_CACHED_TEXT_LENGTH = 1024;
239export function textUnits(text) {
240 const cacheKey = String(text ?? '');
241 const cachedUnits = TEXT_UNITS_CACHE.get(cacheKey);
242 if (cachedUnits !== undefined) return cachedUnits;
243 const chars = Array.from(cacheKey);
244 let units = 0;
245 for (let i = 0; i < chars.length; i += 1) {
246 const codePoint = chars[i].codePointAt(0);
247 if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
248 const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
249 if (next === VARIATION_SELECTOR_EMOJI) units += 2;
250 else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
251 }
252 // Keep repeated in-process compiles bounded, including unusually long labels.
253 if (cacheKey.length <= MAX_CACHED_TEXT_LENGTH) {
254 if (TEXT_UNITS_CACHE.size >= MAX_TEXT_UNITS_CACHE_ENTRIES) {
255 TEXT_UNITS_CACHE.delete(TEXT_UNITS_CACHE.keys().next().value);
256 }
257 TEXT_UNITS_CACHE.set(cacheKey, units);
258 }
259 return units;
260}