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>
409 lines
12 KiB
JavaScript
409 lines
12 KiB
JavaScript
const PORTABLE_PATH_PROFILES = new Set(['output', 'repo', 'archive']);
|
|
const WINDOWS_SAFE_PROFILES = new Set(['output', 'archive']);
|
|
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu;
|
|
const WINDOWS_SHORT_NAME = /~[1-9][0-9]*(?:\.|$)/iu;
|
|
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/u;
|
|
const CONTROL_CHARACTER = /\p{Cc}/u;
|
|
const WINDOWS_INVALID_CHARACTER = /[<>"|?*]/u;
|
|
const MAX_SEMANTIC_VARIANTS = 32;
|
|
|
|
export class PortablePathError extends Error {
|
|
constructor(message, {
|
|
code,
|
|
reason,
|
|
value,
|
|
profile,
|
|
segment,
|
|
segmentIndex,
|
|
character,
|
|
index,
|
|
conflictIndex,
|
|
conflictValue,
|
|
collisionKind,
|
|
semantics,
|
|
pathPart,
|
|
conflictPathPart,
|
|
utf8Bytes,
|
|
utf16CodeUnits,
|
|
limit,
|
|
} = {}) {
|
|
super(message);
|
|
this.name = 'PortablePathError';
|
|
this.code = code;
|
|
this.reason = reason;
|
|
this.value = value;
|
|
this.profile = profile;
|
|
if (segment !== undefined) this.segment = segment;
|
|
if (segmentIndex !== undefined) this.segmentIndex = segmentIndex;
|
|
if (character !== undefined) this.character = character;
|
|
if (index !== undefined) this.index = index;
|
|
if (conflictIndex !== undefined) this.conflictIndex = conflictIndex;
|
|
if (conflictValue !== undefined) this.conflictValue = conflictValue;
|
|
if (collisionKind !== undefined) this.collisionKind = collisionKind;
|
|
if (semantics !== undefined) this.semantics = semantics;
|
|
if (pathPart !== undefined) this.pathPart = pathPart;
|
|
if (conflictPathPart !== undefined) this.conflictPathPart = conflictPathPart;
|
|
if (utf8Bytes !== undefined) this.utf8Bytes = utf8Bytes;
|
|
if (utf16CodeUnits !== undefined) this.utf16CodeUnits = utf16CodeUnits;
|
|
if (limit !== undefined) this.limit = limit;
|
|
}
|
|
}
|
|
|
|
function pathError(value, profile, reason, message, details = {}) {
|
|
return new PortablePathError(message, {
|
|
code: `portable-path/${reason}`,
|
|
reason,
|
|
value,
|
|
profile,
|
|
...details,
|
|
});
|
|
}
|
|
|
|
function assertProfile(profile) {
|
|
if (PORTABLE_PATH_PROFILES.has(profile)) return;
|
|
throw pathError(
|
|
undefined,
|
|
profile,
|
|
'profile',
|
|
`Portable path profile must be one of: ${[...PORTABLE_PATH_PROFILES].join(', ')}.`,
|
|
);
|
|
}
|
|
|
|
export function validatePortablePath(value, options = {}) {
|
|
const profile = options?.profile;
|
|
assertProfile(profile);
|
|
|
|
if (typeof value !== 'string') {
|
|
throw pathError(value, profile, 'type', 'Portable path must be a string.');
|
|
}
|
|
if (value.length === 0) {
|
|
throw pathError(value, profile, 'empty', 'Portable path must not be empty.');
|
|
}
|
|
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('/') || value.startsWith('\\')) {
|
|
throw pathError(value, profile, 'absolute', 'Portable path must be relative.');
|
|
}
|
|
if (/^[A-Za-z]:/u.test(value)) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'drive-relative',
|
|
'Portable path must not use a drive-relative Windows path.',
|
|
);
|
|
}
|
|
if (URI_SCHEME.test(value)) {
|
|
throw pathError(value, profile, 'uri', 'Portable path must not be a URI.');
|
|
}
|
|
if (value.includes('\\')) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'backslash',
|
|
'Portable path must use forward slashes as separators.',
|
|
);
|
|
}
|
|
|
|
const segments = value.split('/');
|
|
for (const [segmentIndex, segment] of segments.entries()) {
|
|
const segmentDetails = { segment, segmentIndex };
|
|
if (segment.length === 0) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'empty-segment',
|
|
'Portable path must not contain empty segments.',
|
|
segmentDetails,
|
|
);
|
|
}
|
|
if (segment === '.' || segment === '..') {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'dot-segment',
|
|
'Portable path must not contain dot segments.',
|
|
segmentDetails,
|
|
);
|
|
}
|
|
const controlMatch = segment.match(CONTROL_CHARACTER);
|
|
if (controlMatch) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'control',
|
|
'Portable path must not contain control characters.',
|
|
{ ...segmentDetails, character: controlMatch[0] },
|
|
);
|
|
}
|
|
const surrogateMatch = segment.match(/[\uD800-\uDFFF]/u);
|
|
if (surrogateMatch) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'unpaired-surrogate',
|
|
'Portable path must not contain unpaired UTF-16 surrogates.',
|
|
{ ...segmentDetails, character: surrogateMatch[0] },
|
|
);
|
|
}
|
|
|
|
if (!WINDOWS_SAFE_PROFILES.has(profile)) continue;
|
|
if (segment.includes(':')) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'windows-ads',
|
|
'Portable path must not select a Windows alternate data stream.',
|
|
{ ...segmentDetails, character: ':' },
|
|
);
|
|
}
|
|
const invalidMatch = segment.match(WINDOWS_INVALID_CHARACTER);
|
|
if (invalidMatch) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'windows-invalid-character',
|
|
'Portable path contains a character that is invalid in Windows file names.',
|
|
{ ...segmentDetails, character: invalidMatch[0] },
|
|
);
|
|
}
|
|
if (/[. ]$/u.test(segment)) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'windows-trailing-dot-space',
|
|
'Portable path segments must not end with a dot or space.',
|
|
segmentDetails,
|
|
);
|
|
}
|
|
if (WINDOWS_RESERVED_NAME.test(segment)) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'windows-reserved-name',
|
|
'Portable path must not use a reserved Windows device name.',
|
|
segmentDetails,
|
|
);
|
|
}
|
|
if (WINDOWS_SHORT_NAME.test(segment)) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'windows-short-name',
|
|
'Portable path must not use a Windows 8.3 short-name shape.',
|
|
segmentDetails,
|
|
);
|
|
}
|
|
const utf8Bytes = Buffer.byteLength(segment, 'utf8');
|
|
const utf16CodeUnits = segment.length;
|
|
if (utf8Bytes > 255 || utf16CodeUnits > 255) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'component-too-long',
|
|
'Portable path segments must fit both UTF-8 and UTF-16 filesystem component limits.',
|
|
{
|
|
...segmentDetails,
|
|
utf8Bytes,
|
|
utf16CodeUnits,
|
|
limit: 255,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function collisionError(values, profile, index, conflictIndex, reason, details = {}) {
|
|
return new PortablePathError(
|
|
`Portable paths at indexes ${conflictIndex} and ${index} collide under portable filesystem semantics.`,
|
|
{
|
|
code: 'portable-path/collision',
|
|
reason,
|
|
value: values[index],
|
|
profile,
|
|
index,
|
|
conflictIndex,
|
|
conflictValue: values[conflictIndex],
|
|
...details,
|
|
},
|
|
);
|
|
}
|
|
|
|
function createSemanticIndex() {
|
|
return new Map();
|
|
}
|
|
|
|
function semanticEnvelope(value, profile, index) {
|
|
const variants = [value];
|
|
const seen = new Set(variants);
|
|
const transforms = [
|
|
(candidate) => candidate.normalize('NFC'),
|
|
(candidate) => candidate.normalize('NFD'),
|
|
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
|
|
(candidate) => candidate.toLocaleLowerCase('en-US'),
|
|
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
|
|
(candidate) => candidate.toLocaleUpperCase('en-US'),
|
|
];
|
|
|
|
for (let cursor = 0; cursor < variants.length; cursor += 1) {
|
|
for (const transform of transforms) {
|
|
const transformed = transform(variants[cursor]);
|
|
if (seen.has(transformed)) continue;
|
|
if (variants.length >= MAX_SEMANTIC_VARIANTS) {
|
|
throw pathError(
|
|
value,
|
|
profile,
|
|
'semantic-expansion',
|
|
'Portable path semantic comparison exceeded its bounded Unicode expansion.',
|
|
{ index, limit: MAX_SEMANTIC_VARIANTS },
|
|
);
|
|
}
|
|
seen.add(transformed);
|
|
variants.push(transformed);
|
|
}
|
|
}
|
|
|
|
return variants;
|
|
}
|
|
|
|
function classifySemanticCollision(value, conflictValue) {
|
|
if (value === conflictValue) return 'exact';
|
|
if (value.normalize('NFC') === conflictValue.normalize('NFC')) return 'normalization';
|
|
|
|
const directLowerMatch = value.toLocaleLowerCase('en-US')
|
|
=== conflictValue.toLocaleLowerCase('en-US');
|
|
const directUpperMatch = value.toLocaleUpperCase('en-US')
|
|
=== conflictValue.toLocaleUpperCase('en-US');
|
|
return directLowerMatch || directUpperMatch ? 'case' : 'case-and-normalization';
|
|
}
|
|
|
|
function findSemanticCollision(semanticIndex, value, profile, sourceIndex) {
|
|
const variants = semanticEnvelope(value, profile, sourceIndex);
|
|
for (const variant of variants) {
|
|
const entry = semanticIndex.get(variant);
|
|
if (entry) {
|
|
return {
|
|
entry,
|
|
variants,
|
|
semantics: classifySemanticCollision(value, entry.value),
|
|
};
|
|
}
|
|
}
|
|
return { entry: null, variants, semantics: null };
|
|
}
|
|
|
|
function rememberSemanticEntry(semanticIndex, entry, variants) {
|
|
for (const variant of variants) {
|
|
if (!semanticIndex.has(variant)) semanticIndex.set(variant, entry);
|
|
}
|
|
}
|
|
|
|
export function validatePortablePathSet(values, options = {}) {
|
|
const profile = options?.profile;
|
|
assertProfile(profile);
|
|
if (!Array.isArray(values)) {
|
|
throw pathError(values, profile, 'set-type', 'Portable path set must be an array.');
|
|
}
|
|
|
|
const leafEntries = createSemanticIndex();
|
|
const directoryEntries = createSemanticIndex();
|
|
|
|
for (const [index, value] of values.entries()) {
|
|
try {
|
|
validatePortablePath(value, { profile });
|
|
} catch (error) {
|
|
if (error instanceof PortablePathError && error.index === undefined) error.index = index;
|
|
throw error;
|
|
}
|
|
|
|
const leafCollision = findSemanticCollision(leafEntries, value, profile, index);
|
|
if (leafCollision.entry) {
|
|
const conflictIndex = leafCollision.entry.index;
|
|
throw collisionError(
|
|
values,
|
|
profile,
|
|
index,
|
|
conflictIndex,
|
|
leafCollision.semantics === 'exact' ? 'duplicate' : leafCollision.semantics,
|
|
{
|
|
collisionKind: 'entry',
|
|
semantics: leafCollision.semantics,
|
|
pathPart: value,
|
|
conflictPathPart: leafCollision.entry.value,
|
|
},
|
|
);
|
|
}
|
|
|
|
const segments = value.split('/');
|
|
const prefixes = segments.slice(0, -1).map((_, prefixIndex) => (
|
|
segments.slice(0, prefixIndex + 1).join('/')
|
|
));
|
|
for (const prefix of prefixes) {
|
|
const directoryCollision = findSemanticCollision(directoryEntries, prefix, profile, index);
|
|
if (directoryCollision.entry && directoryCollision.semantics !== 'exact') {
|
|
throw collisionError(
|
|
values,
|
|
profile,
|
|
index,
|
|
directoryCollision.entry.index,
|
|
`directory-${directoryCollision.semantics}`,
|
|
{
|
|
collisionKind: 'directory-spelling',
|
|
semantics: directoryCollision.semantics,
|
|
pathPart: prefix,
|
|
conflictPathPart: directoryCollision.entry.value,
|
|
},
|
|
);
|
|
}
|
|
|
|
const fileCollision = findSemanticCollision(leafEntries, prefix, profile, index);
|
|
if (fileCollision.entry) {
|
|
throw collisionError(
|
|
values,
|
|
profile,
|
|
index,
|
|
fileCollision.entry.index,
|
|
fileCollision.semantics === 'exact'
|
|
? 'tree-file'
|
|
: `tree-file-${fileCollision.semantics}`,
|
|
{
|
|
collisionKind: 'tree-file',
|
|
semantics: fileCollision.semantics,
|
|
pathPart: prefix,
|
|
conflictPathPart: fileCollision.entry.value,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
const directoryCollision = findSemanticCollision(directoryEntries, value, profile, index);
|
|
if (directoryCollision.entry) {
|
|
throw collisionError(
|
|
values,
|
|
profile,
|
|
index,
|
|
directoryCollision.entry.index,
|
|
directoryCollision.semantics === 'exact'
|
|
? 'tree-file'
|
|
: `tree-file-${directoryCollision.semantics}`,
|
|
{
|
|
collisionKind: 'tree-file',
|
|
semantics: directoryCollision.semantics,
|
|
pathPart: value,
|
|
conflictPathPart: directoryCollision.entry.value,
|
|
},
|
|
);
|
|
}
|
|
|
|
rememberSemanticEntry(leafEntries, { index, value }, leafCollision.variants);
|
|
for (const prefix of prefixes) {
|
|
rememberSemanticEntry(
|
|
directoryEntries,
|
|
{ index, value: prefix },
|
|
semanticEnvelope(prefix, profile, index),
|
|
);
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|