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:
2026-09-30 12:44:15 +03:00
co-authored by Claude Opus 5.5
commit 22815a9940
119 changed files with 66708 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
import path from 'node:path';
import {
containedBy,
isValidWindowsSmbShareName,
isWindowsIpcShare,
resolvePhysicalLocation,
sameLocation,
} from './path-semantics.mjs';
import { PortablePathError, validatePortablePath } from './portable-path.mjs';
export function canonicalFuturePath(targetPath) {
const resolution = resolvePhysicalLocation(targetPath);
if (resolution.status === 'resolved') {
if (resolution.location.kind === 'existing') return resolution.location.path;
return path.join(
resolution.location.ancestorPath,
...resolution.location.unresolved,
);
}
const output = path.resolve(targetPath);
if (resolution.reason.code === 'symlink-cycle') {
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
code: 'output/symlink-cycle',
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
subject: { output },
evidence: { relation: resolution.reason },
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
});
}
throw new OutputPathError('Output path could not be resolved safely.', {
code: 'output/path-resolution-indeterminate',
message: 'Output path could not be resolved safely for the requested filesystem location.',
subject: { output },
evidence: { relation: resolution.reason },
supportedFixes: ['use an ordinary local filesystem path that can be resolved safely, then retry'],
});
}
export function pathsAlias(leftPath, rightPath) {
const relation = sameLocation(leftPath, rightPath);
if (relation.status === 'match') return true;
if (relation.status === 'different') return false;
if (relation.reason.code === 'ancestor-not-directory') return false;
if (relation.reason.code === 'symlink-cycle') {
const output = path.resolve(relation.reason.side === 'right' ? rightPath : leftPath);
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
code: 'output/symlink-cycle',
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
subject: { output },
evidence: { relation: relation.reason },
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
});
}
throw new OutputPathError('Path identity could not be determined safely.', {
code: 'output/path-identity-indeterminate',
message: 'Path identity could not be determined safely for the requested filesystem location.',
subject: { left: path.resolve(leftPath), right: path.resolve(rightPath) },
evidence: { relation: relation.reason },
supportedFixes: ['use ordinary local filesystem paths whose identity can be verified, then retry'],
});
}
function pathIsInside(directoryPath, targetPath) {
const relation = containedBy(directoryPath, targetPath);
if (relation.status === 'match') return true;
if (relation.status === 'different') return false;
throw new OutputPathError('Output containment could not be determined safely.', {
code: 'output/containment-indeterminate',
message: 'Output containment could not be determined safely for the requested filesystem location.',
subject: { output: path.resolve(targetPath), cwd: path.resolve(directoryPath) },
evidence: { relation: relation.reason },
supportedFixes: ['use an output beneath an ordinary local directory whose identity can be verified'],
});
}
function authoredOutputDiagnostic(error, rawOutput) {
const absolute = error?.reason === 'absolute';
const code = absolute ? 'output/meta-absolute' : 'output/meta-path-syntax';
const message = absolute
? 'meta.output must be a relative path resolved from the current working directory.'
: 'meta.output must be a portable POSIX-relative path.';
return {
code,
message,
subject: { output: rawOutput, path: '/meta/output' },
evidence: {
reason: error?.reason || 'invalid',
...(error?.segmentIndex !== undefined ? { segmentIndex: error.segmentIndex } : {}),
...(error?.segment !== undefined ? { segment: error.segment } : {}),
...(error?.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
...(error?.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
...(error?.limit !== undefined ? { limit: error.limit } : {}),
},
supportedFixes: ['set meta.output to a portable relative .html path such as reports/diagram.html'],
};
}
function nativeOutputDiagnostic(rawOutput, reason, details = {}) {
return {
code: 'output/native-path-syntax',
message: 'The output path is not a valid native filesystem path on this host.',
subject: { output: rawOutput },
evidence: { reason, ...details },
supportedFixes: ['choose an ordinary filesystem path without device names, alternate data streams, trailing dots or spaces, or overlong components'],
};
}
function throwNativeOutputDiagnostic(rawOutput, reason, details = {}) {
const diagnostic = nativeOutputDiagnostic(rawOutput, reason, details);
throw new OutputPathError(diagnostic.message, diagnostic);
}
function windowsExtendedTailComponents(rawOutput, tail) {
const authoredComponents = tail.split('\\');
// Preserve one trailing separator for directory arguments, but never repair
// an empty component inside the raw extended namespace.
if (authoredComponents.slice(0, -1).some((component) => component.length === 0)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
return authoredComponents.filter(Boolean);
}
function rejectWindowsIpcShare(rawOutput, share) {
if (isWindowsIpcShare(share)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-ipc-namespace', { share });
}
}
function validateWindowsNativeComponent(
rawOutput,
component,
componentIndex,
{ allowReservedName = false } = {},
) {
try {
// Prefix the component so a colon is classified as an ADS separator,
// rather than allowing the generic URI detector to claim it first.
validatePortablePath(`native/${component}`, { profile: 'output' });
} catch (error) {
if (!(error instanceof PortablePathError)) throw error;
if (allowReservedName && error.reason === 'windows-reserved-name') return;
// Native Windows arguments may intentionally name an existing 8.3 alias.
// Portable authored/archive paths reject that ambiguous spelling, while
// native resolution lets the filesystem prove the existing target.
if (error.reason === 'windows-short-name') {
if (component.length <= 255) return;
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
component,
componentIndex,
utf16CodeUnits: component.length,
limit: 255,
});
}
// Native Windows filesystems bound components in UTF-16 code units. The
// stricter UTF-8 bound belongs to portable authored/archive names only.
if (error.reason === 'component-too-long' && error.utf16CodeUnits <= 255) return;
throwNativeOutputDiagnostic(rawOutput, error.reason, {
component,
componentIndex,
...(error.character !== undefined ? { character: error.character } : {}),
...(error.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
...(error.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
...(error.limit !== undefined ? { limit: error.limit } : {}),
});
}
}
function validateWindowsUncRootComponents(rawOutput, server, share) {
rejectWindowsIpcShare(rawOutput, share);
if (!isValidWindowsSmbShareName(share)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-share-name', {
share,
utf16CodeUnits: share.length,
limit: 80,
});
}
// UNC servers and shares are root components, not DOS file names. Keep the
// server's ordinary native syntax checks while allowing names such as CON.
validateWindowsNativeComponent(rawOutput, server, 0, { allowReservedName: true });
}
function windowsExtendedPathComponents(rawOutput) {
// The extended-length namespace deliberately bypasses Win32 normalization.
// Inspect its original spelling so a dot segment cannot retarget a UNC share.
if (rawOutput.includes('/')) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-separator', { character: '/' });
}
if (/^\\\\\?\\(?:GLOBALROOT|Device)(?:\\|$)/iu.test(rawOutput)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
}
const drive = rawOutput.match(/^\\\\\?\\[A-Za-z]:\\/u);
if (drive) {
return windowsExtendedTailComponents(rawOutput, rawOutput.slice(drive[0].length));
}
const uncPrefix = rawOutput.match(/^\\\\\?\\UNC\\/iu);
if (uncPrefix) {
const authoredTail = rawOutput.slice(uncPrefix[0].length);
const components = authoredTail.split('\\');
if (components.length < 2 || components[0].length === 0 || components[1].length === 0) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
validateWindowsUncRootComponents(rawOutput, components[0], components[1]);
return windowsExtendedTailComponents(rawOutput, components.slice(2).join('\\'));
}
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
function validateWindowsRawUncRoot(rawOutput) {
if (!/^[\\/]{2}/u.test(rawOutput) || /^[\\/]{2}[.?][\\/]/u.test(rawOutput)) return;
// Validate the raw server/share boundary before win32.normalize can collapse
// an empty share or mix the two UNC separator spellings.
const unc = rawOutput.match(/^([\\/])\1([^\\/]+)\1([^\\/]+)(?:[\\/]|$)/u);
if (!unc) {
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
}
validateWindowsUncRootComponents(rawOutput, unc[2], unc[3]);
}
function windowsPathComponents(rawOutput, normalized) {
const authoredUncPrefix = /^[\\/]{2}/u.test(rawOutput);
if (/^\\\\\.\\/u.test(normalized)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
}
if (/^\\\\\?\\/u.test(normalized)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
if (normalized.startsWith('\\\\')) {
const unc = normalized.match(/^\\\\([^\\]+)\\([^\\]+)(?:\\|$)/u);
if (!unc) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
return normalized.slice(unc[0].length).split('\\').filter(Boolean);
}
if (authoredUncPrefix) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
if (normalized.startsWith('\\')) {
throwNativeOutputDiagnostic(rawOutput, 'current-drive-rooted');
}
const root = path.win32.parse(normalized).root;
return normalized
.slice(root.length)
.split(/[\\/]+/u)
// normalize() retains leading navigation for a relative path. Those dot
// segments are path syntax, not filename components subject to name rules.
.filter((component) => component && component !== '.' && component !== '..');
}
/**
* Validate command-line/default output paths using the active host's native
* syntax. Unlike authored portable paths, absolute paths and native separators
* remain supported. File outputs reject a trailing separator before native
* resolution can erase it; directory callers must opt in explicitly. The
* component bound also protects derived sidecars from failing after an
* operation has already started mutating the destination.
*/
export function validateNativeOutputPath(
rawOutput,
{ platform = process.platform, kind = 'file' } = {},
) {
if (kind !== 'file' && kind !== 'directory') {
throw new TypeError(`Unsupported native output path kind: ${JSON.stringify(kind)}`);
}
if (typeof rawOutput !== 'string' || rawOutput.length === 0) {
throwNativeOutputDiagnostic(rawOutput, 'empty');
}
if (rawOutput.includes('\0')) throwNativeOutputDiagnostic(rawOutput, 'nul-character');
if (/[\uD800-\uDFFF]/u.test(rawOutput)) {
throwNativeOutputDiagnostic(rawOutput, 'unpaired-surrogate');
}
const hasTrailingSeparator = platform === 'win32'
? /[\\/]$/u.test(rawOutput)
: rawOutput.endsWith(path.posix.sep);
if (kind === 'file' && hasTrailingSeparator) {
throwNativeOutputDiagnostic(rawOutput, 'trailing-separator', { kind });
}
let normalized;
let components;
if (platform === 'win32') {
if (/^[A-Za-z]:(?:$|[^\\/])/u.test(rawOutput)) {
throwNativeOutputDiagnostic(rawOutput, 'drive-relative');
}
const extendedPrefix = rawOutput.startsWith('\\\\?\\');
if (extendedPrefix) {
components = windowsExtendedPathComponents(rawOutput);
} else {
validateWindowsRawUncRoot(rawOutput);
normalized = path.win32.normalize(rawOutput);
components = windowsPathComponents(rawOutput, normalized);
}
for (const [componentIndex, component] of components.entries()) {
validateWindowsNativeComponent(rawOutput, component, componentIndex);
}
} else {
normalized = path.resolve(rawOutput);
const root = path.parse(normalized).root;
components = normalized.slice(root.length).split(path.sep).filter(Boolean);
for (const [componentIndex, component] of components.entries()) {
const utf8Bytes = Buffer.byteLength(component, 'utf8');
if (utf8Bytes > 255) {
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
component,
componentIndex,
utf8Bytes,
limit: 255,
});
}
}
}
return rawOutput;
}
/** Validate a CLI directory argument before native resolution can normalize away its raw syntax. */
export function resolveNativeOutputDirectory(
rawDirectory,
{ platform = process.platform, cwd = process.cwd() } = {},
) {
validateNativeOutputPath(rawDirectory, { platform, kind: 'directory' });
const pathApi = platform === 'win32' ? path.win32 : path.posix;
return pathApi.resolve(cwd, rawDirectory);
}
export function validateAuthoredOutputPath(rawOutput, { cwd = process.cwd() } = {}) {
try {
validatePortablePath(rawOutput, { profile: 'output' });
} catch (error) {
if (!(error instanceof PortablePathError)) throw error;
const diagnostic = authoredOutputDiagnostic(error, rawOutput);
throw new OutputPathError(diagnostic.message, diagnostic);
}
if (path.posix.extname(rawOutput).toLowerCase() !== '.html') {
throw new OutputPathError('meta.output must target an .html file.', {
code: 'output/meta-extension',
message: 'meta.output must target an .html file.',
subject: { output: rawOutput, path: '/meta/output' },
supportedFixes: ['change meta.output to a portable path ending in .html'],
});
}
const outputPath = path.resolve(cwd, rawOutput);
if (path.extname(canonicalFuturePath(outputPath)).toLowerCase() !== '.html') {
throw new OutputPathError('meta.output must resolve to an .html file.', {
code: 'output/meta-resolved-extension',
message: 'meta.output must resolve to an .html file after symbolic links are followed.',
subject: { output: rawOutput },
supportedFixes: ['remove the symbolic-link alias or point it to an .html target inside the current working directory'],
});
}
if (!pathIsInside(cwd, outputPath)) {
throw new OutputPathError('meta.output must stay inside the current working directory.', {
code: 'output/meta-outside-cwd',
message: 'meta.output must stay inside the current working directory after symbolic links are resolved.',
subject: { output: rawOutput, cwd: path.resolve(cwd) },
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
});
}
return rawOutput;
}
export class OutputPathError extends Error {
constructor(message, diagnostic) {
super(message);
this.name = 'OutputPathError';
this.archifyDiagnostics = [{
severity: 'error',
subject: {},
evidence: {},
supportedFixes: [],
...diagnostic,
}];
}
}
export function resolveOutputPath({
requestedOutput,
authoredOutput,
defaultOutput,
inputPaths = [],
inputDescription = 'an input',
otherOutputPaths = [],
cwd = process.cwd(),
requiredExtension = '.html',
platform = process.platform,
}) {
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput, { cwd });
const source = requestedOutput !== undefined
? 'cli'
: (authoredOutput !== undefined ? 'meta' : 'default');
const rawOutput = source === 'cli'
? requestedOutput
: (source === 'meta' ? authoredOutput : defaultOutput);
if (source !== 'meta') validateNativeOutputPath(rawOutput, { platform, kind: 'file' });
const outputPath = path.resolve(cwd, rawOutput);
for (const inputPath of inputPaths) {
if (!pathsAlias(outputPath, inputPath)) continue;
throw new OutputPathError(`Output must not replace ${inputDescription}.`, {
code: 'output/input-alias',
message: `Output must not replace ${inputDescription}, including through a symbolic-link or future-path alias.`,
subject: { output: outputPath, input: path.resolve(inputPath) },
supportedFixes: ['choose an output path that is distinct from every input path'],
});
}
for (const otherOutputPath of otherOutputPaths) {
if (!pathsAlias(outputPath, otherOutputPath)) continue;
throw new OutputPathError('Output targets must use distinct paths.', {
code: 'output/target-alias',
message: 'Output targets must use distinct paths, including symbolic-link and future-path aliases.',
subject: { output: outputPath, conflictingOutput: path.resolve(otherOutputPath) },
supportedFixes: ['choose distinct paths for every generated output'],
});
}
// Keep explicit CLI directories unrestricted, but reject mistaken file types.
// Alias checks above retain priority when a target would overwrite an input.
if (source === 'cli') {
const resolvedOutput = canonicalFuturePath(outputPath);
const authoredExtension = path.extname(rawOutput).toLowerCase();
const existingWindowsHtmlAlias = platform === 'win32'
&& requiredExtension === '.html'
&& authoredExtension === '.htm'
&& path.extname(resolvedOutput).toLowerCase() === requiredExtension
&& pathsAlias(outputPath, resolvedOutput);
const authoredMatches = authoredExtension === requiredExtension || existingWindowsHtmlAlias;
const resolvedMatches = path.extname(resolvedOutput).toLowerCase() === requiredExtension;
if (!authoredMatches || !resolvedMatches) {
const message = `CLI output must ${authoredMatches ? 'resolve to' : 'target'} a ${requiredExtension} file.`;
throw new OutputPathError(message, {
code: authoredMatches ? 'output/cli-resolved-extension' : 'output/cli-extension',
message,
subject: { output: rawOutput },
evidence: { resolvedOutput, requiredExtension },
supportedFixes: [`choose a path ending in ${requiredExtension} whose symbolic-link target also ends in ${requiredExtension}`],
});
}
}
return {
outputPath,
source,
};
}