From 5f97ef468a303c6fe5b92f27748879ff65d0ce9a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 12:54:37 -0700 Subject: [PATCH 1/2] feat(slidedeck): rich text, links and nested lists Text takes styled runs (bold, italic, underline, strike, theme or hex colour, size, links to URLs or slides). Lists nest to five levels, bulleted or numbered, styled from the master. Validation reports bad URLs, missing slide targets and too-deep lists without recursion. --- packages/slidedeck/src/deck.ts | 194 +++++++++++++++++++++- packages/slidedeck/src/index.ts | 6 + packages/slidedeck/src/layouts.ts | 25 ++- packages/slidedeck/src/theme.ts | 18 +- packages/slidedeck/src/write.ts | 161 +++++++++++++----- packages/slidedeck/src/xml.ts | 15 +- packages/slidedeck/test/fixture.ts | 90 +++++++++- packages/slidedeck/test/slidedeck.test.ts | 69 +++++++- 8 files changed, 515 insertions(+), 63 deletions(-) diff --git a/packages/slidedeck/src/deck.ts b/packages/slidedeck/src/deck.ts index 18a8d8f3..4500666e 100644 --- a/packages/slidedeck/src/deck.ts +++ b/packages/slidedeck/src/deck.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { scope, type } from "arktype"; const Color = /^#?[0-9A-Fa-f]{6}$/; const Font = "0 < string <= 100"; @@ -29,14 +29,76 @@ export type Theme = typeof Theme.infer; const Fit = "'contain' | 'cover' = 'contain'"; +export const ThemeColor = type( + "'primary' | 'secondary' | 'accent' | 'background' | 'text' | 'muted'", +); +export type ThemeColor = typeof ThemeColor.infer; + +const LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +const Url = type("string").narrow( + (text, ctx) => + (URL.canParse(text) && LINK_PROTOCOLS.has(new URL(text).protocol)) || + ctx.mustBe("an http, https or mailto URL"), +); + +/** A URL, or a slide as a zero-based index into `slides` like the paths in validation issues. */ +export const Link = Url.or("number.integer >= 0"); +export type Link = typeof Link.infer; + +export const Run = type({ + "+": "reject", + text: "string", + "bold?": "boolean", + "italic?": "boolean", + "underline?": "boolean", + "strike?": "boolean", + "color?": ThemeColor.or(Color), + /** Points. */ + "size?": "1 <= number <= 400", + "link?": Link, +}); +export type Run = typeof Run.infer; + +/** A plain string, or runs that may each be a plain string. */ +export const RichText = type("string").or(type("string").or(Run).array()); +export type RichText = typeof RichText.infer; + export const Paragraph = type({ "+": "reject", type: "'paragraph'", - text: "string", + text: RichText, +}); + +/** A list item is text, or text with nested items, which are numbered like their parent unless `ordered` says otherwise. */ +export const { ListItem } = scope({ + RichText, + ListItem: [ + "RichText", + "|", + { + "+": "reject", + text: "RichText", + "ordered?": "boolean", + items: "ListItem[] >= 1", + }, + ], +}).export(); +export type ListItem = typeof ListItem.infer; + +/** How deep lists nest; the writer styles each level from the master. */ +export const LIST_DEPTH = 5; + +export const List = type({ + "+": "reject", + type: "'list'", + ordered: "boolean = false", + items: ListItem.array().atLeastLength(1), }); +export type List = typeof List.infer; -/** Blocks that flow as paragraphs in one text box; lists and rich runs join this union. */ -export const TextBlock = Paragraph; +/** Blocks that flow as paragraphs in one text box. */ +export const TextBlock = Paragraph.or(List); export type TextBlock = typeof TextBlock.infer; export const ImageElement = type({ @@ -114,9 +176,131 @@ export type DeckValidation = | { ok: true; deck: Deck } | { ok: false; issues: DeckIssue[] }; +/** The block lists on a slide, with their path under the slide. */ +export function slideRegions(slide: Slide) { + switch (slide.layout) { + case "content": + case "blank": + return [{ role: "body", path: "body", blocks: slide.body }] as const; + case "two-column": + return [ + { role: "left", path: "left", blocks: slide.left }, + { role: "right", path: "right", blocks: slide.right }, + ] as const; + case "comparison": + return [ + { role: "left", path: "left.body", blocks: slide.left.body }, + { role: "right", path: "right.body", blocks: slide.right.body }, + ] as const; + default: + return []; + } +} + +type Path = (string | number)[]; + +const pathString = (path: Path) => + path + .map((key, index) => + typeof key === "number" ? `[${key}]` : index === 0 ? key : `.${key}`, + ) + .join(""); + +function issueAt(path: Path, message: string): DeckIssue { + const [, slide] = path; + const where = pathString(path); + return typeof slide === "number" + ? { slide, path: where, message: `${where} ${message}` } + : { path: where, message: `${where} ${message}` }; +} + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +/** Lists nested past LIST_DEPTH, found without recursion before the schema's own recursion can overflow. */ +function deepLists(input: unknown): DeckIssue[] { + const issues: DeckIssue[] = []; + const pending = [ + { + value: isObject(input) ? input.slides : undefined, + path: ["slides"] as Path, + depth: 0, + }, + ]; + for (let next = pending.pop(); next; next = pending.pop()) { + const { value, path, depth } = next; + if (!isObject(value)) continue; + const entries = Object.entries(value).reverse(); + const opensList = + Array.isArray(value.items) && (depth > 0 || value.type === "list"); + for (const [key, child] of entries) { + const childPath = [...path, Array.isArray(value) ? Number(key) : key]; + if (key !== "items" || !opensList) + pending.push({ value: child, path: childPath, depth }); + else if (depth < LIST_DEPTH) + pending.push({ value: child, path: childPath, depth: depth + 1 }); + else + issues.push( + issueAt(childPath, `nests lists deeper than ${LIST_DEPTH} levels`), + ); + } + } + return issues; +} + +function* textRuns(text: RichText, path: Path): Generator<[Run, Path]> { + if (typeof text === "string") return; + for (const [index, run] of text.entries()) + if (typeof run !== "string") yield [run, [...path, index]]; +} + +function* itemRuns( + items: readonly ListItem[], + path: Path, +): Generator<[Run, Path]> { + for (const [index, item] of items.entries()) { + if (typeof item === "string" || Array.isArray(item)) { + yield* textRuns(item, [...path, index]); + continue; + } + yield* textRuns(item.text, [...path, index, "text"]); + yield* itemRuns(item.items, [...path, index, "items"]); + } +} + +function* slideRuns(slide: Slide, index: number): Generator<[Run, Path]> { + for (const region of slideRegions(slide)) + for (const [position, block] of region.blocks.entries()) { + const path = ["slides", index, region.path, position]; + if (block.type === "paragraph") + yield* textRuns(block.text, [...path, "text"]); + else if (block.type === "list") + yield* itemRuns(block.items, [...path, "items"]); + } +} + +function missingSlideLinks({ slides }: Deck): DeckIssue[] { + return slides + .flatMap((slide, index) => [...slideRuns(slide, index)]) + .filter( + ([run]) => typeof run.link === "number" && run.link >= slides.length, + ) + .map(([run, path]) => + issueAt( + [...path, "link"], + `links to slide ${run.link}, but the deck has ${slides.length} slides`, + ), + ); +} + export function validateDeck(input: unknown): DeckValidation { + const tooDeep = deepLists(input); + if (tooDeep.length > 0) return { ok: false, issues: tooDeep }; const deck = Deck(input); - if (!(deck instanceof type.errors)) return { ok: true, deck }; + if (!(deck instanceof type.errors)) { + const issues = missingSlideLinks(deck); + return issues.length === 0 ? { ok: true, deck } : { ok: false, issues }; + } return { ok: false, issues: deck.map((error) => { diff --git a/packages/slidedeck/src/index.ts b/packages/slidedeck/src/index.ts index ffefad5d..75d942ca 100644 --- a/packages/slidedeck/src/index.ts +++ b/packages/slidedeck/src/index.ts @@ -4,10 +4,16 @@ export { Deck, Element, ImageElement, + Link, + List, + ListItem, Paragraph, + RichText, + Run, Slide, TextBlock, Theme, + ThemeColor, assetKey, validateDeck, type DeckInput, diff --git a/packages/slidedeck/src/layouts.ts b/packages/slidedeck/src/layouts.ts index bec7c0e6..fb29a3bf 100644 --- a/packages/slidedeck/src/layouts.ts +++ b/packages/slidedeck/src/layouts.ts @@ -294,6 +294,29 @@ export function layoutXml(layout: Layout) { return `${XML_DECLARATION}${GROUP_HEADER}${shapes}`; } +const LIST_INDENT = inch(0.35); +const BULLET_HANG = inch(0.25); +const LIST_LEVEL_STYLES = [ + { bullet: "•", numbering: "arabicPeriod", size: 2000 }, + { bullet: "–", numbering: "alphaLcPeriod", size: 1800 }, + { bullet: "•", numbering: "romanLcPeriod", size: 1600 }, + { bullet: "–", numbering: "arabicParenR", size: 1600 }, + { bullet: "•", numbering: "alphaLcParenR", size: 1600 }, +]; + +/** The numbering scheme for a numbered list item; paragraph level 0 is plain text, so lists start at 1. */ +export function numbering(level: number) { + const style = LIST_LEVEL_STYLES[level - 1]; + if (!style) throw new Error(`lists have no level ${level}`); + return style.numbering; +} + +/** Paragraph levels 2–6 carry the list indents, bullets and sizes, in the master and in free text boxes. */ +export const LIST_LEVELS = LIST_LEVEL_STYLES.map( + ({ bullet, size }, index) => + ``, +).join(""); + export function masterXml(logo: string) { // Layouts' untyped content placeholders inherit from the master's body; PowerPoint rejects an untyped one on the master itself. const content = placeholders().content.map((placeholder) => @@ -310,5 +333,5 @@ export function masterXml(logo: string) { ).join(""); const level = (size: number, extra = "") => ``; - return `${XML_DECLARATION}${GROUP_HEADER}${shapes}${logo}${COLOR_MAP}${layoutIds}${level(2000, ' marL="0" indent="0"')}${level(1800)}`; + return `${XML_DECLARATION}${GROUP_HEADER}${shapes}${logo}${COLOR_MAP}${layoutIds}${level(2000, ' marL="0" indent="0"')}${LIST_LEVELS}${level(1800)}`; } diff --git a/packages/slidedeck/src/theme.ts b/packages/slidedeck/src/theme.ts index c89fc47c..50f87d9f 100644 --- a/packages/slidedeck/src/theme.ts +++ b/packages/slidedeck/src/theme.ts @@ -1,9 +1,25 @@ -import type { Theme } from "./deck.ts"; +import type { Theme, ThemeColor } from "./deck.ts"; import { XML_DECLARATION, escapeXml } from "./xml.ts"; const hex = (color: string) => color.replace("#", "").toUpperCase(); const srgb = (color: string) => ``; +const SCHEME_COLORS: Record = { + primary: "accent1", + secondary: "accent2", + accent: "accent3", + background: "bg1", + text: "tx1", + muted: "tx2", +}; + +const isThemeColor = (color: string): color is ThemeColor => + Object.hasOwn(SCHEME_COLORS, color); + +/** A fill that follows the theme for a token name, or a fixed hex colour. */ +export const colorFill = (color: string) => + `${isThemeColor(color) ? `` : srgb(color)}`; + export const COLOR_MAP = ''; diff --git a/packages/slidedeck/src/write.ts b/packages/slidedeck/src/write.ts index 0465fccf..6b9abad7 100644 --- a/packages/slidedeck/src/write.ts +++ b/packages/slidedeck/src/write.ts @@ -2,6 +2,7 @@ import { zip, type ZipEntry } from "@corbits/zip"; import { assetKey, + slideRegions, validateDeck, type AssetRef, type Block, @@ -9,6 +10,10 @@ import { type DeckInput, type DeckIssue, type Element, + type Link, + type ListItem, + type RichText, + type Run, type Slide, type TextBlock, } from "./deck.ts"; @@ -17,11 +22,13 @@ import { FREE_AREA, GROUP_HEADER, LAYOUT_ORDER, + LIST_LEVELS, LOGO_BOX, SLIDE_SIZE, layoutPlaceholders, layoutXml, masterXml, + numbering, placeholderTag, slideNumberField, xfrm, @@ -29,7 +36,7 @@ import { type Placeholder, type Role, } from "./layouts.ts"; -import { COLOR_MAP, themeXml } from "./theme.ts"; +import { COLOR_MAP, colorFill, themeXml } from "./theme.ts"; import { NAMESPACES, Relationships, @@ -50,7 +57,6 @@ export class DeckError extends Error { type Media = { name: string; data: Uint8Array; info: ImageInfo }; type AssetUse = { ref: AssetRef; path: string; slide?: number }; -type Region = { role: Role; path: string; blocks: readonly Block[] }; const CONTENT_TYPE = "application/vnd.openxmlformats-officedocument"; const PRESENTATION = `${CONTENT_TYPE}.presentationml`; @@ -58,36 +64,90 @@ const STACK_GAP = 137160; const lines = (text: string) => text.split(/\r?\n/); -const run = (text: string) => - `${escapeXml(text)}`; - -function paragraph(text: string) { - if (text === "") return ''; - return `${lines(text).map(run).join('')}`; +// PowerPoint numbers adjacent numbered paragraphs at one level as one sequence; an unbulleted paragraph between two lists starts the second at 1. +const LIST_BREAK = + ''; + +const flag = ( + name: string, + value: boolean | undefined, + on: string, + off: string, +) => (value === undefined ? "" : ` ${name}="${value ? on : off}"`); + +function runXml({ text, link, color, size, ...style }: Run, part: SlidePart) { + const attributes = [ + size === undefined ? "" : ` sz="${Math.round(size * 100)}"`, + flag("b", style.bold, "1", "0"), + flag("i", style.italic, "1", "0"), + flag("u", style.underline, "sng", "none"), + flag("strike", style.strike, "sngStrike", "noStrike"), + ].join(""); + const children = `${color ? colorFill(color) : ""}${link === undefined ? "" : part.hyperlink(link)}`; + const properties = children + ? `${children}` + : ``; + return lines(text) + .map((line) => `${properties}${escapeXml(line)}`) + .join(`${properties}`); } -const isText = (block: Block): block is TextBlock => block.type === "paragraph"; +/** A plain string is a single run. */ +function paragraph(text: RichText, part: SlidePart, properties = "") { + const runs = (typeof text === "string" ? [text] : text).map((run) => + typeof run === "string" ? { text: run } : run, + ); + if (runs.every((run) => run.text === "")) + return `${properties}`; + return `${properties}${runs.map((run) => runXml(run, part)).join("")}`; +} -function regions(slide: Slide): Region[] { - switch (slide.layout) { - case "content": - case "blank": - return [{ role: "body", path: "body", blocks: slide.body }]; - case "two-column": - return [ - { role: "left", path: "left", blocks: slide.left }, - { role: "right", path: "right", blocks: slide.right }, - ]; - case "comparison": - return [ - { role: "left", path: "left.body", blocks: slide.left.body }, - { role: "right", path: "right.body", blocks: slide.right.body }, - ]; - default: - return []; +const notesParagraph = (text: string) => + text === "" + ? '' + : `${escapeXml(text)}`; + +/** List items become paragraphs at levels 1 and down, which the master styles as bullets or numbers. */ +function* listParagraphs( + items: readonly ListItem[], + ordered: boolean, + level: number, +): Generator<{ text: RichText; properties: string }> { + for (const item of items) { + const nested = typeof item === "object" && !Array.isArray(item); + yield { + text: nested ? item.text : item, + properties: ordered + ? `` + : ``, + }; + if (nested) + yield* listParagraphs(item.items, item.ordered ?? ordered, level + 1); } } +function textBlockXml(block: TextBlock, part: SlidePart) { + if (block.type === "paragraph") return paragraph(block.text, part); + return [...listParagraphs(block.items, block.ordered, 1)] + .map(({ text, properties }) => paragraph(text, part, properties)) + .join(""); +} + +const isOrderedList = (block: TextBlock | undefined) => + block?.type === "list" && block.ordered; + +function textBodyXml(blocks: readonly TextBlock[], part: SlidePart) { + return blocks + .map( + (block, index) => + `${isOrderedList(block) && isOrderedList(blocks[index - 1]) ? LIST_BREAK : ""}${textBlockXml(block, part)}`, + ) + .join(""); +} + +const isText = (block: Block): block is TextBlock => + block.type === "paragraph" || block.type === "list"; + function assetUses(deck: Deck): AssetUse[] { const uses: AssetUse[] = deck.theme.logo ? [{ ref: deck.theme.logo, path: "theme.logo" }] @@ -95,7 +155,7 @@ function assetUses(deck: Deck): AssetUse[] { for (const [slide, content] of deck.slides.entries()) { if (content.layout === "image") uses.push({ ref: content.image, path: `slides[${slide}].image`, slide }); - for (const { path, blocks } of regions(content)) + for (const { path, blocks } of slideRegions(content)) for (const [index, block] of blocks.entries()) if (block.type === "image") uses.push({ @@ -192,8 +252,8 @@ const picture = ({ }: Picture) => `${nvPr}${crop}${xfrm(box)}`; -const textBox = (id: number, box: Box, paragraphs: string) => - `${xfrm(box)}${paragraphs}`; +const textBox = (id: number, box: Box, paragraphs: string, listStyle: string) => + `${xfrm(box)}${listStyle}${paragraphs}`; const placeholderShape = ( id: number, @@ -222,6 +282,13 @@ class SlidePart { }; } + hyperlink(link: Link) { + if (typeof link === "string") + return ``; + const relId = this.rels.add("slide", `slide${link + 1}.xml`); + return ``; + } + element(element: Element, box: Box) { const { info, relId } = this.image(element.image); this.shapes.push( @@ -272,9 +339,9 @@ function stack( part.element(band.element, box); continue; } - const paragraphs = band.text.map((block) => paragraph(block.text)).join(""); + const paragraphs = textBodyXml(band.text, part); if (!target) { - part.shapes.push(textBox(part.nextId(), box, paragraphs)); + part.shapes.push(textBox(part.nextId(), box, paragraphs, LIST_LEVELS)); continue; } const fills = bands.length === 1 ? undefined : box; @@ -285,29 +352,32 @@ function stack( } } -function textContent(slide: Slide): Partial> { +function textContent( + slide: Slide, + part: SlidePart, +): Partial> { switch (slide.layout) { case "title": case "section": return { - title: paragraph(slide.title), - ...(slide.subtitle && { subtitle: paragraph(slide.subtitle) }), + title: paragraph(slide.title, part), + ...(slide.subtitle && { subtitle: paragraph(slide.subtitle, part) }), }; case "comparison": return { - title: paragraph(slide.title), - leftHeading: paragraph(slide.left.heading), - rightHeading: paragraph(slide.right.heading), + title: paragraph(slide.title, part), + leftHeading: paragraph(slide.left.heading, part), + rightHeading: paragraph(slide.right.heading, part), }; case "image": return { - ...(slide.title && { title: paragraph(slide.title) }), - ...(slide.caption && { caption: paragraph(slide.caption) }), + ...(slide.title && { title: paragraph(slide.title, part) }), + ...(slide.caption && { caption: paragraph(slide.caption, part) }), }; case "blank": return {}; default: - return { title: paragraph(slide.title) }; + return { title: paragraph(slide.title, part) }; } } @@ -340,14 +410,14 @@ function slideXml(deck: Deck, slide: Slide, index: number, media: MediaStore) { `../slideLayouts/slideLayout${LAYOUT_ORDER.indexOf(slide.layout) + 1}.xml`, ); const content: Partial> = { - ...textContent(slide), - ...(deck.footer?.text && { footer: paragraph(deck.footer.text) }), + ...textContent(slide, part), + ...(deck.footer?.text && { footer: paragraph(deck.footer.text, part) }), ...(deck.footer?.slideNumbers && { slideNumber: `${slideNumberField(String(index + 1))}`, }), }; - const byRole = new Map( - regions(slide).map((region) => [region.role, region.blocks]), + const byRole = new Map( + slideRegions(slide).map((region) => [region.role, region.blocks]), ); if (slide.layout === "blank") stack(part, slide.body, FREE_AREA); for (const placeholder of layoutPlaceholders(slide.layout)) { @@ -369,7 +439,8 @@ function slideXml(deck: Deck, slide: Slide, index: number, media: MediaStore) { } function notesXml(notes: string) { - return `${XML_DECLARATION}${GROUP_HEADER}${lines(notes).map(paragraph).join("")}`; + const paragraphs = lines(notes).map(notesParagraph).join(""); + return `${XML_DECLARATION}${GROUP_HEADER}${paragraphs}`; } const NOTES_MASTER = `${XML_DECLARATION}${GROUP_HEADER}${xfrm({ x: 685800, y: 1143000, w: 5486400, h: 3086100 })}${xfrm({ x: 685800, y: 4400550, w: 5486400, h: 3600450 })}${COLOR_MAP}`; diff --git a/packages/slidedeck/src/xml.ts b/packages/slidedeck/src/xml.ts index e7cec646..6af548d5 100644 --- a/packages/slidedeck/src/xml.ts +++ b/packages/slidedeck/src/xml.ts @@ -16,13 +16,18 @@ export const escapeXml = (text: string) => .replace(/>/g, ">") .replace(/"/g, """); -type Relationship = { id: string; type: string; target: string }; +type Relationship = { + id: string; + type: string; + target: string; + external: boolean; +}; const relationshipsXml = (items: readonly Relationship[]) => `${XML_DECLARATION}${items .map( - ({ id, type, target }) => - ``, + ({ id, type, target, external }) => + ``, ) .join("")}`; @@ -31,9 +36,9 @@ export class Relationships { readonly #items: Relationship[] = []; /** `type` is a full URI, or a name under the officeDocument relationships namespace. */ - add(type: string, target: string) { + add(type: string, target: string, external = false) { const id = `rId${this.#items.length + 1}`; - this.#items.push({ id, type, target }); + this.#items.push({ id, type, target, external }); return id; } diff --git a/packages/slidedeck/test/fixture.ts b/packages/slidedeck/test/fixture.ts index b7bec66b..b076d047 100644 --- a/packages/slidedeck/test/fixture.ts +++ b/packages/slidedeck/test/fixture.ts @@ -74,14 +74,89 @@ export const deck: DeckInput = { title: "What Builder does", body: [ paragraph("Turns an idea into approved documents."), - paragraph("Designs from your tokens & ."), + { + type: "paragraph", + text: [ + "Designs from ", + { text: "your tokens", bold: true, color: "primary" }, + " & : ", + { text: "italic", italic: true }, + ", ", + { text: "underlined", underline: true }, + ", ", + { text: "struck", strike: true }, + " and ", + { text: "large\ntext", size: 28, color: "#EA580C" }, + ".", + ], + }, + { + type: "list", + items: [ + "Plans the work", + { + text: "Writes the documents", + items: [ + "Constraints", + { + text: "Approaches", + items: [ + { + text: "Design concepts", + items: [{ text: "Theme", items: ["Tokens"] }], + }, + ], + }, + ], + }, + ], + }, ], notes: "Keep it short.\r\nTwo lines of notes.", }, { layout: "two-column", title: "Inputs and outputs", - left: [paragraph("Idea, constraints")], + left: [ + paragraph("Idea, constraints"), + { + type: "list", + ordered: true, + items: [ + [ + "Read the ", + { + text: "guide", + link: "HTTPS://corbits.dev/guide/über?a=1&b=2", + }, + ], + { + text: "Describe the idea", + items: [ + "Who it is for", + { text: "What it does", items: ["Core job"] }, + ], + }, + { + text: [{ text: "Ask questions", link: 7 }], + ordered: false, + items: [ + [ + { + text: "Email us", + link: "mailto:hello@corbits.dev", + }, + ], + ], + }, + ], + }, + { + type: "list", + ordered: true, + items: ["Review the plan", "Approve it"], + }, + ], right: [ paragraph("Docs, design, app"), { type: "image", image: mockup }, @@ -113,7 +188,16 @@ export const deck: DeckInput = { }, { layout: "blank", - body: [paragraph("Questions?")], + body: [ + paragraph("Questions?"), + { + type: "list", + items: [ + [{ text: "Back to the start", link: 0 }], + { text: "Contact", items: ["hello@corbits.dev"] }, + ], + }, + ], notes: "Leave time for questions.", }, ], diff --git a/packages/slidedeck/test/slidedeck.test.ts b/packages/slidedeck/test/slidedeck.test.ts index c929ebba..f0bcb1c4 100644 --- a/packages/slidedeck/test/slidedeck.test.ts +++ b/packages/slidedeck/test/slidedeck.test.ts @@ -66,20 +66,39 @@ test("the fixture deck writes the same bytes twice and every part reads back", a }); if (!path.endsWith(".rels")) continue; const base = `file:///${path.replace(/_rels\/[^/]+$/, "")}`; - for (const [, target = ""] of xml.matchAll(/Target="([^"]+)"/g)) - expect(paths).toContain(new URL(target, base).pathname.slice(1)); + for (const [, target = "", external] of xml.matchAll( + /Target="([^"]+)"( TargetMode="External")?/g, + )) + if (external) expect(target).toMatch(/^(https?|mailto):/); + else expect(paths).toContain(new URL(target, base).pathname.slice(1)); } }); test("an invalid deck names the slide and field", () => { const result = validateDeck({ ...deck, - slides: [deck.slides[0], { layout: "content", body: [] }], + slides: [ + deck.slides[0], + { layout: "content", body: [] }, + { + layout: "blank", + body: [ + { + type: "paragraph", + text: [{ text: "Run", link: "JavaScript:alert(1)" }], + }, + ], + }, + ], }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.issues).toEqual([ expect.objectContaining({ slide: 1, path: "slides[1].title" }), + expect.objectContaining({ + slide: 2, + path: "slides[2].body[0].text[0].link", + }), ]); }); @@ -93,3 +112,47 @@ test("the slide master's content placeholder is a typed body, which PowerPoint n .match(/]*>/g); expect(placeholders).toContain(''); }); + +test("a missing slide link or a list past five levels names the slide and field", () => { + const nested = (levels: number) => { + let item: unknown = "Too deep"; + for (let level = levels - 1; level > 0; level--) + item = { text: `Level ${level}`, items: [item] }; + return { layout: "blank", body: [{ type: "list", items: [item] }] }; + }; + const link = { text: "Appendix", link: 9 }; + expect( + validateDeck({ + ...deck, + slides: [ + deck.slides[0], + { layout: "blank", body: [{ type: "list", items: [[link]] }] }, + ], + }), + ).toEqual({ + ok: false, + issues: [ + { + slide: 1, + path: "slides[1].body[0].items[0][0].link", + message: + "slides[1].body[0].items[0][0].link links to slide 9, but the deck has 2 slides", + }, + ], + }); + + const tooDeep = `slides[1].body[0]${".items[0]".repeat(5)}.items`; + for (const levels of [6, 10_000]) + expect( + validateDeck({ ...deck, slides: [deck.slides[0], nested(levels)] }), + ).toEqual({ + ok: false, + issues: [ + { + slide: 1, + path: tooDeep, + message: `${tooDeep} nests lists deeper than 5 levels`, + }, + ], + }); +}); From 281991fe36a200482cddb6eaf3470ef61202d4ca Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 13:30:22 -0700 Subject: [PATCH 2/2] refactor(slidedeck): issue paths without a nested ternary --- packages/slidedeck/src/deck.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/slidedeck/src/deck.ts b/packages/slidedeck/src/deck.ts index 4500666e..80f16c13 100644 --- a/packages/slidedeck/src/deck.ts +++ b/packages/slidedeck/src/deck.ts @@ -199,12 +199,13 @@ export function slideRegions(slide: Slide) { type Path = (string | number)[]; -const pathString = (path: Path) => - path - .map((key, index) => - typeof key === "number" ? `[${key}]` : index === 0 ? key : `.${key}`, - ) - .join(""); +function pathKey(key: string | number, index: number) { + if (typeof key === "number") return `[${key}]`; + if (index === 0) return key; + return `.${key}`; +} + +const pathString = (path: Path) => path.map(pathKey).join(""); function issueAt(path: Path, message: string): DeckIssue { const [, slide] = path;