import { escapeHtml as esc, localizeTemplate, resolveLocale, translateMessage, viewerCatalog, } from './i18n.mjs'; export { esc }; export function renderDefinitions() { return ` `; } const SIGIL_TONE = { frontend: 'frontend', start: 'frontend', backend: 'backend', active: 'backend', database: 'database', success: 'database', cloud: 'cloud', waiting: 'cloud', security: 'security', failure: 'security', messagebus: 'messagebus', external: 'external', neutral: 'external', }; const SIGIL_SHAPE = { frontend: ` `, backend: ``, database: ` `, cloud: ``, security: ` `, messagebus: ` `, external: ` `, start: ` `, active: ``, waiting: ``, success: ` `, failure: ` `, neutral: ` `, }; // A quiet, renderer-owned role stamp. It is authored SVG content rather than a // viewer overlay, so it survives canonical export while adding no focus target, // accessible name, layout box, or interaction state of its own. export function renderSemanticSigil(kind, { x, y, size = 11 } = {}) { const normalized = Object.hasOwn(SIGIL_SHAPE, kind) ? kind : 'neutral'; const tone = SIGIL_TONE[normalized] || 'external'; const scale = size / 16; return ``; } export function renderCards(cards) { const list = Array.isArray(cards) ? cards : []; return `
${list.map((card) => `

${esc(card.title)}

    ${card.items.map((item) => `
  • • ${esc(item)}
  • `).join('\n')}
`).join('\n\n')}
`; } const SVG_SLOT_RE = / [\s\S]*? /; const CARDS_SLOT_RE = / [\s\S]*? /; const SUBTITLE_SLOT_RE = /^([ \t]*)

\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m; const GUIDED_VIEWS_PLACEHOLDER = ''; const SOURCE_EVIDENCE_PLACEHOLDER = ' '; const I18N_PLACEHOLDER = ' '; function serializeScriptJson(value) { return JSON.stringify(value) .replaceAll('<', '\\u003c') .replaceAll('>', '\\u003e') .replaceAll('&', '\\u0026'); } const TEMPLATE_PLACEHOLDERS = [ '', '[PROJECT NAME] Architecture Diagram', '

[PROJECT NAME] Architecture

', GUIDED_VIEWS_PLACEHOLDER, ]; export function applyTemplate(template, { title, subtitle, svg, cards, locale, visualPreset = 'classic', guidedViews = [], sourceEvidence = null, }) { if (!SVG_SLOT_RE.test(template)) { throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel'); } if (!CARDS_SLOT_RE.test(template)) { throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel'); } if (!SUBTITLE_SLOT_RE.test(template)) { throw new Error('applyTemplate: template missing subtitle placeholder'); } for (const ph of TEMPLATE_PLACEHOLDERS) { if (!template.includes(ph)) { throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`); } } // Keep existing custom templates compatible when evidence is not requested. // Silently dropping verified evidence would be misleading, so the new slot // becomes mandatory only for the opt-in evidence path. if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) { throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`); } // Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels, // or rendered SVG must not be interpreted as a replacement pattern. const guidedViewsJson = serializeScriptJson(guidedViews); const sourceEvidenceJson = serializeScriptJson(sourceEvidence); const resolvedLocale = resolveLocale(locale); const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) }); const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim() ? `

${esc(subtitle)}

` : ''; const i18nData = ` `; const localizedTemplate = localizeTemplate(template, resolvedLocale); const templateWithI18n = localizedTemplate.includes(I18N_PLACEHOLDER) ? localizedTemplate.replace(I18N_PLACEHOLDER, () => i18nData) : localizedTemplate.replace(GUIDED_VIEWS_PLACEHOLDER, () => `${i18nData}\n ${GUIDED_VIEWS_PLACEHOLDER}`); return templateWithI18n .replace(TEMPLATE_PLACEHOLDERS[0], () => ``) .replace(TEMPLATE_PLACEHOLDERS[1], () => `${esc(translateMessage(resolvedLocale, 'page.title', { title }))}`) .replace(TEMPLATE_PLACEHOLDERS[2], () => `

${esc(title)}

`) .replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle ? `${indent}${renderedSubtitle}${newline}` : '') .replace(SVG_SLOT_RE, () => svg) .replace(CARDS_SLOT_RE, () => cards) .replace(GUIDED_VIEWS_PLACEHOLDER, () => ``) .replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence ? ` ` : ''); } // CJK and other wide/fullwidth glyphs render at roughly twice the advance // width of ASCII in the monospace stacks the template uses. Keep halfwidth // forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges // also cover vertical punctuation and supplementary East Asian scripts that // literal glyph ranges made difficult to audit. // Code points that take two columns of advance width: East Asian Wide and // Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols // carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which // render at the same square advance as the supplementary-plane emoji already // listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming: // Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram / // digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and // Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because // U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK // ranges UAX #11 names default to Neutral rather than Wide. Spelled out as // ranges because V8 has no \p{East_Asian_Width=W} property escape. const 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; // A variation selector (U+FE00-U+FE0F) carries no advance of its own: it // re-presents the character before it. VS15 (U+FE0E) asks for text // presentation, which renders narrow; VS16 (U+FE0F) asks for emoji // presentation, which renders at the square emoji advance. So a base plus a // selector is measured from the selector, not from the base -- otherwise // widening the emoji-presentation bases above turns U+2B50 U+FE0F from two // units into three while the glyph on screen stays one square, and leaves // U+2708 U+FE0F at two only because its base happens to be narrow. // // A selector following a base that cannot take emoji presentation is // malformed input; measuring it wide is the safe direction here, since // over-measuring pads a box while under-measuring spills the label out of it. const VARIATION_SELECTOR_FIRST = 0xfe00; const VARIATION_SELECTOR_LAST = 0xfe0f; const VARIATION_SELECTOR_TEXT = 0xfe0e; const VARIATION_SELECTOR_EMOJI = 0xfe0f; export function textUnits(text) { const chars = Array.from(String(text ?? '')); let units = 0; for (let i = 0; i < chars.length; i += 1) { const codePoint = chars[i].codePointAt(0); if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue; const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1; if (next === VARIATION_SELECTOR_EMOJI) units += 2; else if (next === VARIATION_SELECTOR_TEXT) units += 1; else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1; } return units; }