diff --git a/builtin-modules/doc-core.json b/builtin-modules/doc-core.json index 1e78973..630c0c6 100644 --- a/builtin-modules/doc-core.json +++ b/builtin-modules/doc-core.json @@ -3,8 +3,8 @@ "description": "Format-agnostic document infrastructure — themes, colour validation, contrast utilities, input guards. Used by ha:pptx, ha:pdf, and other format modules.", "author": "system", "mutable": false, - "sourceHash": "sha256:b9119a600839812d", - "dtsHash": "sha256:1f311b99f56fdcbb", + "sourceHash": "sha256:dffdb06f7812466e", + "dtsHash": "sha256:b39c60e35b4359bd", "importStyle": "named", "hints": { "overview": "Shared utilities for all document formats. Provides themes, colour validation (WCAG AA contrast), and input guards. You rarely import this directly — ha:pptx and ha:pdf re-use it internally.", diff --git a/builtin-modules/src/doc-core.ts b/builtin-modules/src/doc-core.ts index 0998f5e..c791371 100644 --- a/builtin-modules/src/doc-core.ts +++ b/builtin-modules/src/doc-core.ts @@ -12,16 +12,35 @@ // ── Colour Utilities ───────────────────────────────────────────────── +/** Regex for a valid 6-character hex colour (with optional #). */ +const HEX_RE = /^#?[0-9A-Fa-f]{6}$/; + /** - * Convert a hex colour string to normalised format (strip leading #, uppercase). - * This is the **lenient** version — it does NOT throw on bad input. - * Prefer `requireHex()` at public API boundaries; this is kept for - * internal paths where the value has already been validated. + * Normalise and validate a hex colour string. + * + * Throws on invalid input (non-hex strings, XML fragments, named + * colours, rgb() notation, etc.) to prevent malformed OOXML output. + * Prefer `requireHex()` at public API boundaries for more descriptive + * error messages with parameter names. * * @param hex - Colour like "#2196F3" or "2196F3" * @returns Normalised colour like "2196F3" + * @throws {Error} If hex is not a valid 6-character hex colour */ export function hexColor(hex: string): string { + if (typeof hex !== "string" || !HEX_RE.test(hex)) { + // Safely render non-string values without risking Symbol/object toString + const display = + typeof hex === "string" + ? hex.length > 30 + ? hex.slice(0, 30) + "..." + : hex + : `[${typeof hex}]`; + throw new Error( + `Invalid hex colour: "${display}". ` + + `Expected a 6-character hex string like "2196F3" or "#FF9800".`, + ); + } return hex.replace(/^#/, "").toUpperCase(); } @@ -314,8 +333,7 @@ export function isDark(hex: string): boolean { // three layers deep. Every error message is LLM-actionable: it tells // the caller WHAT is wrong, WHY, and HOW to fix it. -/** Regex for a valid 6-character hex colour (with optional #). */ -const HEX_RE = /^#?[0-9A-Fa-f]{6}$/; +// HEX_RE is defined at the top of the file alongside hexColor(). /** * Validate and normalise a hex colour string. diff --git a/builtin-modules/src/types/ha-modules.d.ts b/builtin-modules/src/types/ha-modules.d.ts index fe9538e..0d88510 100644 --- a/builtin-modules/src/types/ha-modules.d.ts +++ b/builtin-modules/src/types/ha-modules.d.ts @@ -43,13 +43,16 @@ declare module "ha:crc32" { declare module "ha:doc-core" { /** - * Convert a hex colour string to normalised format (strip leading #, uppercase). - * This is the **lenient** version — it does NOT throw on bad input. - * Prefer `requireHex()` at public API boundaries; this is kept for - * internal paths where the value has already been validated. + * Normalise and validate a hex colour string. + * + * Throws on invalid input (non-hex strings, XML fragments, named + * colours, rgb() notation, etc.) to prevent malformed OOXML output. + * Prefer `requireHex()` at public API boundaries for more descriptive + * error messages with parameter names. * * @param hex - Colour like "#2196F3" or "2196F3" * @returns Normalised colour like "2196F3" + * @throws {Error} If hex is not a valid 6-character hex colour */ export declare function hexColor(hex: string): string; /** diff --git a/tests/docgen-modules.test.ts b/tests/docgen-modules.test.ts index 41617f8..6585030 100644 --- a/tests/docgen-modules.test.ts +++ b/tests/docgen-modules.test.ts @@ -51,6 +51,50 @@ describe("ooxml-core", () => { it("should handle already-clean input", () => { expect(core.hexColor("ABCDEF")).toBe("ABCDEF"); }); + + it("should throw on XML fragment input", () => { + expect(() => + core.hexColor( + '', + ), + ).toThrow("Invalid hex colour"); + }); + + it("should throw on named colour", () => { + expect(() => core.hexColor("red")).toThrow("Invalid hex colour"); + }); + + it("should throw on 3-char shorthand", () => { + expect(() => core.hexColor("FFF")).toThrow("Invalid hex colour"); + }); + + it("should throw on rgb() notation", () => { + expect(() => core.hexColor("rgb(255,0,0)")).toThrow("Invalid hex colour"); + }); + + it("should throw on empty string", () => { + expect(() => core.hexColor("")).toThrow("Invalid hex colour"); + }); + + it("should truncate long strings in error message", () => { + const longXml = "" + "x".repeat(100); + try { + core.hexColor(longXml); + } catch (e: unknown) { + const msg = (e as Error).message; + expect(msg).toContain("..."); + expect(msg.length).toBeLessThan(200); + } + }); + + it("should safely handle non-string input", () => { + expect(() => core.hexColor(42 as unknown as string)).toThrow( + "Invalid hex colour", + ); + expect(() => core.hexColor(null as unknown as string)).toThrow( + "Invalid hex colour", + ); + }); }); describe("themes", () => {