diff --git a/packages/slidedeck/src/deck.ts b/packages/slidedeck/src/deck.ts index 18a8d8f3..80f16c13 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,132 @@ 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)[]; + +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; + 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`, + }, + ], + }); +});