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>
441 lines
19 KiB
JavaScript
441 lines
19 KiB
JavaScript
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,
|
|
};
|
|
}
|