line-and-surface/lint_intro.ts
2025-07-11 21:17:32 +02:00

572 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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<string, string>;
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("<file>", "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<string, string> {
const properties: Record<string, string> = {};
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<string>();
// 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<string, ElementInfo[]>();
// 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();