Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions builtin-modules/doc-core.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
30 changes: 24 additions & 6 deletions builtin-modules/src/doc-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
simongdavies marked this conversation as resolved.
*
* @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".`,
);
}
Comment thread
simongdavies marked this conversation as resolved.
return hex.replace(/^#/, "").toUpperCase();
}

Expand Down Expand Up @@ -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.
Expand Down
11 changes: 7 additions & 4 deletions builtin-modules/src/types/ha-modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
44 changes: 44 additions & 0 deletions tests/docgen-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<p:bg><p:bgPr><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></p:bgPr></p:bg>',
),
).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 = "<a:gradFill>" + "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", () => {
Expand Down
Loading