Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+451
@@ -0,0 +1,451 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { applyTemplate, renderCards, esc } from './utils.mjs';
|
||||
import { validateSchema } from './validator.mjs';
|
||||
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
|
||||
import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
|
||||
import { validateEngineeringProfile } from './engineering-profiles.mjs';
|
||||
import {
|
||||
resolveOutputPath,
|
||||
validateAuthoredOutputPath,
|
||||
} from './output-path.mjs';
|
||||
import {
|
||||
captureAtomicOutput,
|
||||
captureRegularFileBinding,
|
||||
publishRegularFileBinding,
|
||||
releaseRegularFileBinding,
|
||||
removeOwnedRegularFile,
|
||||
verifyAtomicOutput,
|
||||
} from './atomic-output.mjs';
|
||||
import { resolveLocale, translateMessage, registerLocale, SUPPORTED_LOCALES } from './i18n.mjs';
|
||||
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
|
||||
|
||||
const outputPathGuards = new Map();
|
||||
let renderCandidateSequence = 0;
|
||||
|
||||
// meta.locale is renderer-owned Viewer UI, not authored content.
|
||||
// en and zh-CN ship as built-in catalogs.
|
||||
// Any other tag needs meta.translations (validated against the English
|
||||
// message-key set, layered over English per-key so partial/invalid entries
|
||||
// never break rendering) or it falls back to the English Viewer chrome —
|
||||
// the same "omit locale, disclose the fallback" contract as before, just
|
||||
// resolved from data instead of a hard-coded enum. See i18n.mjs.
|
||||
function applyLocaleTranslations(diagramType, diagram) {
|
||||
const locale = diagram.meta?.locale;
|
||||
if (!locale) return;
|
||||
const translations = diagram.meta?.translations;
|
||||
if (translations && Object.keys(translations).length) {
|
||||
const report = registerLocale(locale, translations);
|
||||
if (report.missingKeys.length || report.unknownKeys.length || report.placeholderMismatches.length) {
|
||||
recordDiagnostic({
|
||||
code: 'i18n/translation-coverage',
|
||||
severity: 'warning',
|
||||
message: `meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`,
|
||||
subject: { diagramType, path: '/meta/translations' },
|
||||
evidence: {
|
||||
missingKeys: report.missingKeys.slice(0, 10),
|
||||
missingKeysTotal: report.missingKeys.length,
|
||||
unknownKeys: report.unknownKeys.slice(0, 10),
|
||||
unknownKeysTotal: report.unknownKeys.length,
|
||||
placeholderMismatches: report.placeholderMismatches.slice(0, 10),
|
||||
placeholderMismatchesTotal: report.placeholderMismatches.length,
|
||||
},
|
||||
supportedFixes: ['Add the missing keys to meta.translations.', 'Match each translation\'s {placeholders} to the English source string.'],
|
||||
});
|
||||
// Coverage is a fact about this render, not just a diagnostic-mode
|
||||
// artifact: print it to stderr unconditionally so `render`/`deliver`/
|
||||
// `validate` disclose the fallback even without ARCHIFY_DIAGNOSTIC_FORMAT.
|
||||
console.warn(`archify: meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`);
|
||||
}
|
||||
} else if (!SUPPORTED_LOCALES.includes(locale)) {
|
||||
recordDiagnostic({
|
||||
code: 'i18n/locale-fallback',
|
||||
severity: 'warning',
|
||||
message: `meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`,
|
||||
subject: { diagramType, path: '/meta/locale' },
|
||||
supportedFixes: ['Supply meta.translations for this locale.', `Use a built-in locale: ${SUPPORTED_LOCALES.join(', ')}.`],
|
||||
});
|
||||
console.warn(`archify: meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Common CLI head: node render-<type>.mjs [input.json] [output.html]
|
||||
// Keep this synchronous because callers also use it to establish the guarded
|
||||
// output path before testing a last-moment filesystem alias change.
|
||||
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
|
||||
// Compilers also import this module for SVG helpers. Only CLI execution
|
||||
// should install a process-level handler, before reading or validating input.
|
||||
installRendererDiagnosticBoundary();
|
||||
const skillRoot = path.resolve(rendererDir, '../..');
|
||||
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
|
||||
let input;
|
||||
try {
|
||||
input = fs.readFileSync(inputPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (!isFilesystemError(error)) throw error;
|
||||
const message = `Input could not be read: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'input/read', message,
|
||||
subject: { input: inputPath },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['provide one readable JSON input file'],
|
||||
}]);
|
||||
}
|
||||
let diagram;
|
||||
try {
|
||||
diagram = JSON.parse(input);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error;
|
||||
const message = `Input JSON could not be parsed: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'input/json-parse', message,
|
||||
subject: { input: inputPath },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['repair the JSON syntax and run validation again'],
|
||||
}]);
|
||||
}
|
||||
const authoredOutput = diagram?.meta?.output;
|
||||
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
|
||||
validateSchema(diagramType, diagram);
|
||||
applyLocaleTranslations(diagramType, diagram);
|
||||
validateCrossCollectionContracts(diagramType, diagram);
|
||||
validateEngineeringProfile(diagramType, diagram);
|
||||
const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const outputRequest = {
|
||||
requestedOutput: argv[3],
|
||||
authoredOutput: diagram.meta?.output,
|
||||
defaultOutput: `${diagramType}.html`,
|
||||
inputPaths: [inputPath],
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
let outPath;
|
||||
try {
|
||||
({ outputPath: outPath } = resolveOutputPath(outputRequest));
|
||||
} catch (error) {
|
||||
throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
|
||||
}
|
||||
outputPathGuards.set(outPath, outputRequest);
|
||||
return { diagram, template, outPath, sourceEvidence };
|
||||
}
|
||||
|
||||
// Brand URL capture is the only asynchronous authoring step. Typed renderers
|
||||
// opt into it through this wrapper without changing loadDiagram's long-lived
|
||||
// synchronous safety contract.
|
||||
export async function loadDiagramWithBrandMarks(options) {
|
||||
const loaded = loadDiagram(options);
|
||||
await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
||||
|
||||
function isFilesystemError(error) {
|
||||
return typeof error?.code === 'string'
|
||||
&& typeof error?.syscall === 'string'
|
||||
&& typeof error?.errno === 'number';
|
||||
}
|
||||
|
||||
function throwOutputError(error, output) {
|
||||
if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
|
||||
const message = `Output could not be written: ${error.message}`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'output/write', message,
|
||||
subject: { output },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
|
||||
}]);
|
||||
}
|
||||
|
||||
function throwAtomicOutputFailure(result, output) {
|
||||
const reason = result.reason || { code: 'unclassified' };
|
||||
const changed = result.status === 'different';
|
||||
const candidate = reason.code.startsWith('candidate-');
|
||||
const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
|
||||
const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
|
||||
const message = changed
|
||||
? 'Output target changed while the rendered artifact was being prepared.'
|
||||
: nonRegular
|
||||
? candidate
|
||||
? 'Temporary output candidate is no longer a regular file.'
|
||||
: 'Output already exists and is not a regular file.'
|
||||
: hardlinked
|
||||
? candidate
|
||||
? 'Temporary output candidate has multiple hard-link names.'
|
||||
: 'Output already exists through multiple hard-link names.'
|
||||
: 'Output target stability could not be determined safely before commit.';
|
||||
throwDiagnosticError(message, [{
|
||||
code: changed
|
||||
? 'output/target-changed'
|
||||
: nonRegular
|
||||
? 'output/target-not-regular-file'
|
||||
: hardlinked
|
||||
? 'output/target-hardlinked'
|
||||
: 'output/target-indeterminate',
|
||||
message,
|
||||
subject: { output },
|
||||
evidence: { relation: reason },
|
||||
supportedFixes: [hardlinked
|
||||
? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
|
||||
: 'retry after other processes stop replacing or redirecting the output path'],
|
||||
}]);
|
||||
}
|
||||
|
||||
function stageRenderedHtml(outputPath, html, mode) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
renderCandidateSequence += 1;
|
||||
const candidatePath = path.join(
|
||||
path.dirname(outputPath),
|
||||
`.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
|
||||
);
|
||||
let descriptor;
|
||||
let identity;
|
||||
try {
|
||||
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
|
||||
descriptor = fs.openSync(
|
||||
candidatePath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
|
||||
mode ?? 0o666,
|
||||
);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = fs.fstatSync(descriptor, { bigint: true });
|
||||
} catch (error) {
|
||||
// A transient first inspection failure must not strand the exclusive
|
||||
// candidate. A successful retry binds cleanup to the still-open file;
|
||||
// if both inspections fail, preserving the unknown entry is safer.
|
||||
try {
|
||||
const retry = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (retry.isFile() && retry.ino !== 0n) {
|
||||
identity = { device: retry.dev, inode: retry.ino };
|
||||
}
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
if (!metadata.isFile() || metadata.ino === 0n) {
|
||||
throw new Error('Temporary render candidate identity could not be verified safely.');
|
||||
}
|
||||
identity = { device: metadata.dev, inode: metadata.ino };
|
||||
fs.writeFileSync(descriptor, html);
|
||||
// Creation modes are filtered through the process umask. An atomic
|
||||
// replacement must retain the exact permissions of an existing target,
|
||||
// while a brand-new target should keep normal umask behavior.
|
||||
if (mode !== null) fs.fchmodSync(descriptor, mode);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
return { candidatePath, identity };
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) {
|
||||
try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
if (error.code === 'EEXIST') continue;
|
||||
if (identity) {
|
||||
const cleanup = removeOwnedRegularFile(candidatePath, identity);
|
||||
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
||||
const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
|
||||
cleanupError.cause = error;
|
||||
throw cleanupError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
|
||||
error.code = 'EEXIST';
|
||||
error.errno = -17;
|
||||
error.syscall = 'open';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Common CLI tail: fill the template and write the standalone HTML file.
|
||||
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
|
||||
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
|
||||
const outputGuard = outputPathGuards.get(outPath);
|
||||
const html = applyTemplate(template, {
|
||||
title: meta.title,
|
||||
subtitle: meta.subtitle,
|
||||
svg,
|
||||
cards: renderCards(cards),
|
||||
locale: meta.locale,
|
||||
visualPreset: meta.visual_preset || 'classic',
|
||||
sourceEvidence,
|
||||
});
|
||||
let candidatePath;
|
||||
let candidateIdentity;
|
||||
let candidateBinding;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
const outputCapture = captureAtomicOutput(outPath);
|
||||
if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
|
||||
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
|
||||
({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
|
||||
outputCapture.commitPath,
|
||||
html,
|
||||
outputCapture.mode,
|
||||
));
|
||||
|
||||
// The renderer may spend substantial time building HTML after loadDiagram
|
||||
// establishes the guard. Re-run it after staging so a last-moment alias
|
||||
// cannot redirect the commit onto an input file.
|
||||
if (outputGuard) resolveOutputPath(outputGuard);
|
||||
const candidateCapture = captureRegularFileBinding(candidatePath, {
|
||||
subject: 'candidate',
|
||||
expectedSha256: createHash('sha256').update(html).digest('hex'),
|
||||
expectedBytes: Buffer.byteLength(html),
|
||||
expectedIdentity: candidateIdentity,
|
||||
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
|
||||
});
|
||||
if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
|
||||
candidateBinding = candidateCapture.binding;
|
||||
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
|
||||
if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
|
||||
const publication = publishRegularFileBinding(
|
||||
candidateBinding,
|
||||
candidatePath,
|
||||
outputCapture.snapshot,
|
||||
{ subject: 'candidate' },
|
||||
);
|
||||
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
|
||||
throwAtomicOutputFailure(publication, outPath);
|
||||
}
|
||||
const releasedCandidate = releaseRegularFileBinding(candidateBinding);
|
||||
candidateBinding = undefined;
|
||||
if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
|
||||
candidatePath = undefined;
|
||||
candidateIdentity = undefined;
|
||||
} catch (error) {
|
||||
throwOutputError(error, outPath);
|
||||
} finally {
|
||||
outputPathGuards.delete(outPath);
|
||||
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
|
||||
if (candidatePath && candidateIdentity) {
|
||||
const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
|
||||
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
||||
throwAtomicOutputFailure(cleanup, outPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(outPath);
|
||||
}
|
||||
|
||||
const SEMANTIC_COLLECTIONS = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
const RELATIONSHIP_COLLECTIONS = {
|
||||
architecture: 'connections',
|
||||
workflow: 'edges',
|
||||
sequence: 'messages',
|
||||
dataflow: 'flows',
|
||||
lifecycle: 'transitions',
|
||||
};
|
||||
|
||||
// Relationship IDs are optional for backwards compatibility, but once an
|
||||
// author supplies one it becomes the durable identity used by viewer links.
|
||||
// Keep uniqueness enforcement in the shared zero-install path so every typed
|
||||
// renderer fails the same way even when development dependencies are absent.
|
||||
export function validateRelationshipIds(diagramType, diagram) {
|
||||
const collection = RELATIONSHIP_COLLECTIONS[diagramType];
|
||||
const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
||||
const seen = new Set();
|
||||
const problems = [];
|
||||
|
||||
relationships.forEach((relationship, index) => {
|
||||
if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
|
||||
if (seen.has(relationship.id)) {
|
||||
problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
|
||||
}
|
||||
seen.add(relationship.id);
|
||||
});
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Relationship identity validation failed', problems, {
|
||||
code: 'relationship/duplicate-id',
|
||||
subject: { diagramType, collection },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Share relationship-ID semantic checks between the loader
|
||||
// and workflow compiler without performing filesystem operations (see #429).
|
||||
export function validateCrossCollectionContracts(diagramType, diagram) {
|
||||
validateRelationshipIds(diagramType, diagram);
|
||||
}
|
||||
|
||||
// Accessible name for the generated diagram SVG.
|
||||
export function svgRootAttrs(meta, explicitQualityProfile) {
|
||||
const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
|
||||
const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
|
||||
const engineeringProfile = meta.engineering_profile
|
||||
? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
|
||||
: '';
|
||||
const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
|
||||
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
|
||||
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
|
||||
return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
|
||||
}
|
||||
|
||||
// Keep the accessible name inside the SVG so it survives standalone SVG
|
||||
// export and embedding. The fixed IDs are deterministic because an Archify
|
||||
// artifact intentionally contains one primary diagram SVG.
|
||||
export function svgAccessibleText(meta, kind) {
|
||||
const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
|
||||
return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
|
||||
}
|
||||
|
||||
export function animateAttr(meta, kind, step) {
|
||||
if (meta.animation !== 'trace') return '';
|
||||
// Ambient trace must finish inside the fixed six-second WebM capture. The
|
||||
// cap affects visual delay only; authored order and semantic identity stay
|
||||
// untouched in the JSON, DOM, and relationship contracts.
|
||||
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
|
||||
return ` data-animate="${kind}" style="--step:${safeStep}"`;
|
||||
}
|
||||
|
||||
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
|
||||
// the schema's conservative identifier pattern; escape again at the markup
|
||||
// boundary so these helpers remain safe if that contract expands later.
|
||||
export function focusNodeAttrs(id, label, metadata = {}, locale) {
|
||||
const optional = [
|
||||
['data-node-kind', metadata.kind],
|
||||
['data-node-sublabel', metadata.sublabel],
|
||||
['data-node-tag', metadata.tag],
|
||||
['data-node-context', metadata.context],
|
||||
['data-node-brand', metadata.brand],
|
||||
['data-node-brand-id', metadata.brandId],
|
||||
['data-node-brand-status', metadata.brandStatus],
|
||||
['data-node-brand-source', metadata.brandSource],
|
||||
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
|
||||
.join('');
|
||||
const detail = [metadata.sublabel, metadata.context, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.join(', ');
|
||||
const aria = detail
|
||||
? translateMessage(locale, 'node.focus.detail', { label, detail })
|
||||
: translateMessage(locale, 'node.focus', { label });
|
||||
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
|
||||
}
|
||||
|
||||
// Native SVG titles preserve a compact details-on-demand fallback when the
|
||||
// canonical SVG is embedded inline outside the full Archify viewer.
|
||||
export function focusNodeTitle(label, metadata = {}) {
|
||||
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
|
||||
return `<title>${esc(parts.join(' · '))}</title>`;
|
||||
}
|
||||
|
||||
export function focusEdgeAttrs(from, to, label, key, id) {
|
||||
const named = label ? ` data-edge-label="${esc(label)}"` : '';
|
||||
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
|
||||
const identified = id !== undefined && id !== null && String(id).trim() !== ''
|
||||
? ` data-edge-id="${esc(String(id))}"`
|
||||
: '';
|
||||
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
|
||||
}
|
||||
Reference in New Issue
Block a user