#!/usr/bin/env node import * as fs from "fs"; import * as path from "path"; import { Command } from "@commander-js/extra-typings"; import { DOMParser, Document, Element } from "@xmldom/xmldom"; interface ElementInfo { type: string; element: Element; properties: Record; descriptions: string[]; } interface LintWarning { code: string; message: string; element?: ElementInfo; } interface LintContext { filePath: string; fileDir: string; svgDoc: Document; elements: ElementInfo[]; warnings: LintWarning[]; } // Setup the command line interface const program = new Command() .name("svg-linter") .description("Lints SVG files according to our constraints") .version("0.1.0") .argument("", "Path to the SVG file to lint") .option( "--inspect", "Inspect mode - outputs all SVG elements and their properties" ) .parse(); const options = program.opts(); /** * Reads and parses an SVG file * @param filePath Path to the SVG file * @returns The parsed SVG document */ function parseSvgFile(filePath: string): Document { try { const resolvedPath = path.resolve(filePath); const svgContent = fs.readFileSync(resolvedPath, "utf-8"); const parser = new DOMParser(); return parser.parseFromString(svgContent, "image/svg+xml"); } catch (error) { console.error( `Error reading or parsing SVG file: ${ error instanceof Error ? error.message : String(error) }` ); process.exit(1); } } /** * Extracts properties from an SVG element * @param element The SVG element to extract properties from * @returns Record of property name to value */ function extractElementProperties(element: Element): Record { const properties: Record = {}; for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i]; properties[attr.name] = attr.value; } return properties; } /** * Extracts all elements of a specific type from an SVG document * @param doc The SVG document * @param elementType The element type to extract * @returns Array of elements with their properties */ /** * Extract desc elements from a parent element * @param element The parent element * @returns Array of text content from desc elements */ function extractDescElements(element: Element): string[] { const descriptions: string[] = []; const descElements = element.getElementsByTagName("desc"); for (let i = 0; i < descElements.length; i++) { const descElement = descElements[i]; const textContent = descElement.textContent; if (textContent) { descriptions.push(textContent.trim()); } } return descriptions; } function extractElements(doc: Document, elementType: string): ElementInfo[] { const elements = doc.getElementsByTagName(elementType); const result: ElementInfo[] = []; for (let i = 0; i < elements.length; i++) { const element = elements[i]; result.push({ type: elementType, element: element, properties: extractElementProperties(element), descriptions: extractDescElements(element), }); } return result; } /** * Linting functions */ // Lint: There should be exactly one element with an id "start", and it should be a rect function lintStartRect(context: LintContext): void { const startElements = context.elements.filter( (el) => el.properties.id === "start" ); if (startElements.length === 0) { context.warnings.push({ code: "START_MISSING", message: "No element with id 'start' found. One rect with id='start' is required.", }); return; } if (startElements.length > 1) { context.warnings.push({ code: "START_DUPLICATE", message: `Found ${startElements.length} elements with id 'start'. Only one is allowed.`, }); } const nonRectStarts = startElements.filter((el) => el.type !== "rect"); if (nonRectStarts.length > 0) { context.warnings.push({ code: "START_NOT_RECT", message: `Element with id 'start' must be a rect, but found: ${nonRectStarts .map((el) => el.type) .join(", ")}.`, element: nonRectStarts[0], }); } } // Lint: For circles with a description element, the description should point to a relative path to an existing file function lintCircleDescriptions(context: LintContext): void { const circlesWithDesc = context.elements.filter( (el) => el.type === "circle" && el.descriptions.length > 0 ); circlesWithDesc.forEach((circle) => { circle.descriptions.forEach((desc) => { // Check if description appears to be a file path if (desc.includes("/") || desc.includes(".")) { // Try to resolve the path relative to the SVG file's directory const descPath = path.resolve(context.fileDir, desc); if (!fs.existsSync(descPath)) { context.warnings.push({ code: "CIRCLE_DESC_FILE_MISSING", message: `Circle (id=${circle.properties.id || "no-id"}) has description pointing to non-existent file: ${desc}`, element: circle, }); } } }); }); } // Lint: If there's an ellipse with any description, emit a warning function lintEllipseDescriptions(context: LintContext): void { const ellipsesWithDesc = context.elements.filter( (el) => el.type === "ellipse" && el.descriptions.length > 0 ); ellipsesWithDesc.forEach((ellipse) => { // Calculate average radius const rx = parseFloat(ellipse.properties.rx || "0"); const ry = parseFloat(ellipse.properties.ry || "0"); const avgRadius = (rx + ry) / 2; context.warnings.push({ code: "ELLIPSE_WITH_DESC", message: `Ellipse (id=${ellipse.properties.id || "no-id"}) has a description. It will be processed as a circle with radius ${avgRadius} (average of rx=${rx} and ry=${ry}).`, element: ellipse, }); }); } // Lint: Check that hyperlinks starting with "anchor" point to existing anchors and all anchors are referenced function lintHyperlinkAnchors(context: LintContext): void { // Find all anchor elements (rect elements with IDs starting with "anchor") const anchorElements = context.elements.filter( (el) => el.type === "rect" && el.properties.id?.startsWith("anchor") ); // Find all hyperlinks (a elements) const aElements = Array.from(context.svgDoc.getElementsByTagName("a")); // Track anchors that have been referenced const referencedAnchors = new Set(); // Check all hyperlinks for valid anchor references aElements.forEach((aElement) => { const href = aElement.getAttribute("xlink:href"); if (!href) return; if (href.startsWith("anchor")) { // This is an anchor reference - check if the anchor exists const anchorId = href.startsWith("#") ? href.substring(1) : href; const targetAnchor = anchorElements.find( (el) => el.properties.id === anchorId ); if (!targetAnchor) { // Hyperlink points to a non-existent anchor context.warnings.push({ code: "BROKEN_ANCHOR_LINK", message: `Hyperlink points to non-existent anchor: ${href}`, }); } else { // Mark this anchor as referenced referencedAnchors.add(anchorId); } } }); // Check for unreferenced anchors anchorElements.forEach((anchor) => { if (!referencedAnchors.has(anchor.properties.id)) { context.warnings.push({ code: "UNREFERENCED_ANCHOR", message: `Anchor element with id '${anchor.properties.id}' is not referenced by any hyperlink.`, element: anchor, }); } }); } // Lint: Validate that image elements with descriptions follow the proper format for video scrolls function lintVideoScrollPaths(context: LintContext): void { const imagesWithDesc = context.elements.filter( (el) => el.type === "image" && el.descriptions.length > 0 ); imagesWithDesc.forEach((image) => { const desc = image.descriptions[0]; const descLines = desc.split("\n").filter((line) => line.trim().length > 0); // Check that the description has at least 2 lines if (descLines.length < 2) { context.warnings.push({ code: "INVALID_SCROLL_DESC_FORMAT", message: `Image (id=${image.properties.id || "no-id"}) has a description that doesn't follow the required format: first line for direction, second line for file path.`, element: image, }); return; } // First line should be a valid direction const directionLine = descLines[0]; const validDirections = [ "up", "down", "left", "right", "up left", "up right", "down left", "down right", ]; // Check if any valid direction is in the direction line const hasValidDirection = validDirections.some((dir) => directionLine.toLowerCase().includes(dir) ); if (!hasValidDirection) { context.warnings.push({ code: "INVALID_SCROLL_DIRECTION", message: `Image (id=${image.properties.id || "no-id"}) has a description with invalid scroll direction: "${directionLine}". Valid directions: ${validDirections.join( ", " )}.`, element: image, }); } // Second line should point to an existing file const filePath = descLines[1].replace(/^\//, ""); try { const fullPath = path.resolve(context.fileDir, filePath); if (!fs.existsSync(fullPath)) { context.warnings.push({ code: "SCROLL_FILE_MISSING", message: `Image (id=${image.properties.id || "no-id"}) references a non-existent file in description: ${filePath}`, element: image, }); } } catch (error) { // Path resolution error context.warnings.push({ code: "SCROLL_PATH_ERROR", message: `Error checking path for image (id=${image.properties.id || "no-id"}): ${error instanceof Error ? error.message : String(error)}`, element: image, }); } // Also check if xlink:href exists and points to a valid directory const xlinkHref = image.properties["xlink:href"]; if (!xlinkHref) { context.warnings.push({ code: "SCROLL_MISSING_HREF", message: `Image (id=${image.properties.id || "no-id"}) with scroll description is missing xlink:href attribute`, element: image, }); } else if (!xlinkHref.includes("/")) { context.warnings.push({ code: "INVALID_SCROLL_PATH", message: `Image (id=${image.properties.id || "no-id"}) has an xlink:href that doesn't point to a directory: ${xlinkHref}`, element: image, }); } }); } // Lint: Check that all element IDs are unique across the SVG function lintIdUniqueness(context: LintContext): void { // Create a map to track IDs and their elements const idMap = new Map(); // Collect all elements with IDs context.elements.forEach((element) => { if (element.properties.id) { const elementsWithId = idMap.get(element.properties.id) || []; elementsWithId.push(element); idMap.set(element.properties.id, elementsWithId); } }); // Check for duplicates idMap.forEach((elements, id) => { if (elements.length > 1) { context.warnings.push({ code: "DUPLICATE_ID", message: `ID '${id}' is used by ${ elements.length } elements: ${elements.map((e) => e.type).join(", ")}.`, element: elements[0], }); } }); } // Lint: Check that files referenced in videoscroll file lists exist function lintVideoScrollFileContents(context: LintContext): void { // Find all image elements with descriptions that follow the videoscroll format const imagesWithDesc = context.elements.filter( (el) => el.type === "image" && el.descriptions.length > 0 ); // Process only images with valid descriptions (at least 2 lines) imagesWithDesc.forEach((image) => { const desc = image.descriptions[0]; const descLines = desc.split("\n").filter((line) => line.trim().length > 0); // Skip if the description doesn't have at least 2 lines if (descLines.length < 2) return; // Get the file list path from the second line const fileListPath = descLines[1].replace(/^\//, ""); try { // Resolve the full path to the file list const fullFileListPath = path.resolve(context.fileDir, fileListPath); // Skip if the file list doesn't exist (already checked in lintVideoScrollPaths) if (!fs.existsSync(fullFileListPath)) return; // Read the file list contents const fileListContent = fs.readFileSync(fullFileListPath, "utf-8"); const referencedFiles = fileListContent .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); // Get the directory containing the file list const fileListDir = path.dirname(fullFileListPath); // Check if each referenced file exists const missingFiles: string[] = []; referencedFiles.forEach((referencedFile) => { const fullFilePath = path.resolve(fileListDir, referencedFile); if (!fs.existsSync(fullFilePath)) { missingFiles.push(referencedFile); } }); // If there are missing files, emit a warning if (missingFiles.length > 0) { context.warnings.push({ code: "SCROLL_MISSING_FILES", message: `Image (id=${image.properties.id || "no-id"}) references a file list (${fileListPath}) that contains ${ missingFiles.length } missing files: ${missingFiles.join(", ")}.`, element: image, }); } } catch (error) { // Error reading or parsing the file list context.warnings.push({ code: "SCROLL_FILE_LIST_ERROR", message: `Error reading file list for image (id=${image.properties.id || "no-id"}): ${error instanceof Error ? error.message : String(error)}`, element: image, }); } }); } // Run all lint checks function runAllLints(context: LintContext): void { lintStartRect(context); lintCircleDescriptions(context); lintEllipseDescriptions(context); lintHyperlinkAnchors(context); lintVideoScrollPaths(context); lintIdUniqueness(context); lintVideoScrollFileContents(context); } // Output warnings function outputWarnings(context: LintContext): void { if (context.warnings.length === 0) { console.log("✅ SVG file passes all lints."); return; } console.log("\n⚠️ Lint Warnings:"); console.log("=================\n"); context.warnings.forEach((warning, index) => { console.log(`${index + 1}. [${warning.code}] ${warning.message}`); if (warning.element) { console.log( ` Element: ${warning.element.type}${ warning.element.properties.id ? ` (id=${warning.element.properties.id})` : "" }` ); } console.log(""); }); console.log(`Total warnings: ${context.warnings.length}`); } // Output inspect details function outputInspectDetails(elements: ElementInfo[]): void { console.log("\nElements found in the SVG:"); console.log("========================\n"); if (elements.length === 0) { console.log( "No matching elements (image, rect, ellipse, circle) found in the SVG." ); return; } elements.forEach((element, index) => { console.log(`${index + 1}. Type: ${element.type}`); console.log(" Properties:"); Object.entries(element.properties).forEach(([name, value]) => { if (value.length > 128) { console.log(` - ${name}: ${value.substring(0, 128)}...`); } else { console.log(` - ${name}: ${value}`); } }); // Output descriptions if any exist if (element.descriptions.length > 0) { console.log(" Descriptions:"); element.descriptions.forEach((desc, descIndex) => { if (desc.length > 128) { console.log(` - ${descIndex + 1}: ${desc.substring(0, 128)}...`); } else { console.log(` - ${descIndex + 1}: ${desc}`); } }); } console.log(""); }); console.log(`Total elements found: ${elements.length}`); } /** * Main function to process the SVG file */ function main() { const options = program.opts(); const file = program.args[0]; console.log(`Processing SVG file: ${file}`); const svgDoc = parseSvgFile(file); const fileDir = path.dirname(path.resolve(file)); // Extract elements of interest const elementTypes = ["image", "rect", "ellipse", "circle"]; const allElements: ElementInfo[] = []; elementTypes.forEach((type) => { const elements = extractElements(svgDoc, type); allElements.push(...elements); }); // Create lint context const context: LintContext = { filePath: file, fileDir: fileDir, svgDoc: svgDoc, elements: allElements, warnings: [], }; // Run all lint checks runAllLints(context); // Output inspection details if requested if (options.inspect) { outputInspectDetails(allElements); } // Output warnings outputWarnings(context); // Exit with appropriate code if (context.warnings.length > 0) { process.exit(1); } else { process.exit(0); } } // Execute the main function main();