572 lines
17 KiB
JavaScript
572 lines
17 KiB
JavaScript
#!/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();
|