@tycoworks/tycoslide 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +10 -6
  2. package/dist/agents/catalog.d.ts +32 -0
  3. package/dist/agents/catalog.js +37 -0
  4. package/dist/agents/commands.d.ts +9 -0
  5. package/dist/agents/commands.js +57 -0
  6. package/dist/agents/extract.d.ts +22 -0
  7. package/dist/agents/extract.js +54 -0
  8. package/dist/{files.d.ts → agents/files.d.ts} +13 -3
  9. package/dist/{files.js → agents/files.js} +16 -3
  10. package/dist/agents/inventory.d.ts +83 -0
  11. package/dist/agents/inventory.js +568 -0
  12. package/dist/agents/manifest.d.ts +3 -0
  13. package/dist/{manifest.js → agents/manifest.js} +3 -14
  14. package/dist/agents/media.d.ts +38 -0
  15. package/dist/agents/media.js +83 -0
  16. package/dist/agents/pptx.d.ts +48 -0
  17. package/dist/agents/pptx.js +123 -0
  18. package/dist/agents/skill.d.ts +43 -0
  19. package/dist/agents/skill.js +134 -0
  20. package/dist/cli.js +8 -44
  21. package/dist/engine/dom.d.ts +3 -0
  22. package/dist/engine/dom.js +4 -0
  23. package/dist/engine/fillers/image.d.ts +6 -5
  24. package/dist/engine/fillers/image.js +24 -7
  25. package/dist/engine/generate.d.ts +2 -2
  26. package/dist/engine/generate.js +2 -2
  27. package/dist/engine/index.d.ts +3 -0
  28. package/dist/engine/index.js +2 -0
  29. package/dist/engine/notes.js +20 -62
  30. package/dist/engine/ooxml.d.ts +36 -0
  31. package/dist/engine/ooxml.js +56 -0
  32. package/dist/engine/types.d.ts +4 -0
  33. package/dist/index.d.ts +4 -11
  34. package/dist/index.js +2 -15
  35. package/dist/markdown/blocks/code.js +1 -2
  36. package/dist/markdown/blocks/image.d.ts +23 -0
  37. package/dist/markdown/blocks/image.js +65 -3
  38. package/dist/markdown/blocks/mermaid.d.ts +1 -1
  39. package/dist/markdown/blocks/mermaid.js +6 -4
  40. package/dist/markdown/blocks/table.js +7 -6
  41. package/dist/markdown/blocks/text.js +9 -8
  42. package/dist/markdown/deckCompiler.d.ts +2 -13
  43. package/dist/markdown/deckCompiler.js +7 -78
  44. package/dist/markdown/index.d.ts +4 -2
  45. package/dist/markdown/index.js +3 -1
  46. package/dist/markdown/inline.d.ts +2 -0
  47. package/dist/markdown/inline.js +4 -3
  48. package/dist/markdown/schema/themeConfigSchema.d.ts +2 -11
  49. package/dist/markdown/schema/themeConfigSchema.js +6 -16
  50. package/dist/markdown/slideParser.d.ts +2 -0
  51. package/dist/markdown/slideParser.js +6 -3
  52. package/dist/markdown/types.d.ts +4 -29
  53. package/dist/markdown/types.js +0 -11
  54. package/{theme-package/syntax.md → docs/markdown.md} +60 -28
  55. package/docs/theme.md +144 -0
  56. package/package.json +3 -2
  57. package/theme-package/SKILL.md +24 -90
  58. package/dist/engine/rels.d.ts +0 -18
  59. package/dist/engine/rels.js +0 -55
  60. package/dist/manifest.d.ts +0 -5
  61. package/dist/skillZip.d.ts +0 -49
  62. package/dist/skillZip.js +0 -177
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Media: copy the images a template uses, on its masters, layouts and slides, out
3
+ * of the .pptx under their original media filenames, as raw material for a theme's
4
+ * image catalog.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join, posix } from "node:path";
9
+ import { imageSize } from "image-size";
10
+ import { Part, RelType } from "./pptx.js";
11
+ const XML_EXT = ".xml";
12
+ /** What happened to one referenced image. */
13
+ export const MediaOutcome = {
14
+ Copied: "copied",
15
+ /** A file of that name was already in the output folder, and was left untouched. */
16
+ Present: "present",
17
+ /** Byte-identical to an image already handled under another name, so not copied twice. */
18
+ Duplicate: "duplicate",
19
+ /** Referenced by a relationship but absent from the package. */
20
+ Missing: "missing",
21
+ };
22
+ /**
23
+ * Copy every image the presentation's masters, layouts and slides use into
24
+ * `outDir` (created if missing), and describe each in first-use order: masters
25
+ * first, then layouts, then slides, each in number order. Never overwrites: an
26
+ * image whose filename is already there is left as it is.
27
+ */
28
+ export async function copyImages(presentation, outDir) {
29
+ mkdirSync(outDir, { recursive: true });
30
+ const references = await imageReferences(presentation);
31
+ const images = [];
32
+ const firstByContent = new Map();
33
+ for (const [media, usedBy] of references) {
34
+ const file = posix.basename(media);
35
+ if (!presentation.has(media)) {
36
+ images.push({ file, usedBy, outcome: MediaOutcome.Missing });
37
+ continue;
38
+ }
39
+ const data = await presentation.bytes(media);
40
+ const size = pixelSize(data);
41
+ const read = { file, usedBy, ...(size && { size }) };
42
+ const digest = createHash("sha256").update(data).digest("hex");
43
+ const duplicateOf = firstByContent.get(digest);
44
+ if (duplicateOf) {
45
+ images.push({ ...read, outcome: MediaOutcome.Duplicate, duplicateOf });
46
+ continue;
47
+ }
48
+ firstByContent.set(digest, file);
49
+ const dest = join(outDir, file);
50
+ if (existsSync(dest)) {
51
+ images.push({ ...read, outcome: MediaOutcome.Present });
52
+ continue;
53
+ }
54
+ writeFileSync(dest, data);
55
+ images.push({ ...read, outcome: MediaOutcome.Copied });
56
+ }
57
+ return images;
58
+ }
59
+ /** Media part → the masters, layouts and slides that use it, in first-use order. */
60
+ async function imageReferences(presentation) {
61
+ const references = new Map();
62
+ const parts = [Part.Master, Part.Layout, Part.Slide].flatMap((pattern) => presentation.numbered(pattern));
63
+ for (const part of parts) {
64
+ const user = posix.basename(part, XML_EXT);
65
+ for (const media of await presentation.related(part, RelType.Image)) {
66
+ const users = references.get(media) ?? [];
67
+ if (!users.includes(user))
68
+ users.push(user);
69
+ references.set(media, users);
70
+ }
71
+ }
72
+ return references;
73
+ }
74
+ /** Pixel size of an image, or undefined when its format isn't readable. */
75
+ function pixelSize(data) {
76
+ try {
77
+ const { width, height } = imageSize(new Uint8Array(data));
78
+ return width && height ? { width, height } : undefined;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Reading a .pptx from the agent layer: open it, parse its parts once, and follow
3
+ * its relationships. Built on the core's generic package helpers; the names of the
4
+ * parts and relationships the agent layer looks for live here.
5
+ */
6
+ import { type Relationship } from "../index.js";
7
+ /** Archive paths of the parts the agent layer reads, numbered by their file name. */
8
+ export declare const Part: {
9
+ readonly Slide: RegExp;
10
+ readonly Master: RegExp;
11
+ readonly Layout: RegExp;
12
+ readonly EmbeddedFont: RegExp;
13
+ };
14
+ /** Tail segments of the relationship types the agent layer follows. */
15
+ export declare const RelType: {
16
+ readonly Image: "/image";
17
+ readonly Theme: "/theme";
18
+ readonly SlideLayout: "/slideLayout";
19
+ };
20
+ /** `ppt/slides/slide12.xml` → 12, for a part matching one of `Part`'s patterns. */
21
+ export declare function partNumber(part: string, pattern: RegExp): number;
22
+ /** A .pptx opened for reading: its parts, each parsed once, and where their relationships lead. */
23
+ export declare class Presentation {
24
+ private readonly parsed;
25
+ private readonly zip;
26
+ private constructor();
27
+ /** Open `path`, failing with a plain explanation when it is missing or not a .pptx. */
28
+ static open(path: string): Promise<Presentation>;
29
+ /** Every file in the package. */
30
+ get parts(): string[];
31
+ /** The parts matching `pattern`, in number order. */
32
+ numbered(pattern: RegExp): string[];
33
+ has(part: string): boolean;
34
+ bytes(part: string): Promise<Buffer>;
35
+ /** The part's root element. */
36
+ xml(part: string): Promise<any>;
37
+ private file;
38
+ /** The part's relationships to other parts (external links left out), with targets resolved. */
39
+ relationships(part: string): Promise<Relationship[]>;
40
+ /** The parts `part` relates to with a relationship type ending in `suffix`. */
41
+ related(part: string, suffix: string): Promise<string[]>;
42
+ }
43
+ /** The element reached from `el` by a path of child tag names, or undefined. */
44
+ export declare function find(el: any, ...path: string[]): any;
45
+ /** `el`'s element children, only those named `tag` when given; none when `el` is missing. */
46
+ export declare function elementChildren(el: any, tag?: string): any[];
47
+ /** `el`'s attributes by name; none when `el` is missing. */
48
+ export declare function attributes(el: any): Record<string, string>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Reading a .pptx from the agent layer: open it, parse its parts once, and follow
3
+ * its relationships. Built on the core's generic package helpers; the names of the
4
+ * parts and relationships the agent layer looks for live here.
5
+ */
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
+ import JSZip from "jszip";
8
+ import { childrenByTag, PRESENTATION_PART, parseXml, readRelationships, relsPathFor, resolveTarget, } from "../index.js";
9
+ /** Archive paths of the parts the agent layer reads, numbered by their file name. */
10
+ export const Part = {
11
+ Slide: /^ppt\/slides\/slide(\d+)\.xml$/,
12
+ Master: /^ppt\/slideMasters\/slideMaster(\d+)\.xml$/,
13
+ Layout: /^ppt\/slideLayouts\/slideLayout(\d+)\.xml$/,
14
+ EmbeddedFont: /^ppt\/fonts\/.+\.fntdata$/,
15
+ };
16
+ /** Tail segments of the relationship types the agent layer follows. */
17
+ export const RelType = {
18
+ Image: "/image",
19
+ Theme: "/theme",
20
+ SlideLayout: "/slideLayout",
21
+ };
22
+ /** `ppt/slides/slide12.xml` → 12, for a part matching one of `Part`'s patterns. */
23
+ export function partNumber(part, pattern) {
24
+ return Number(pattern.exec(part)?.[1]);
25
+ }
26
+ /** A .pptx opened for reading: its parts, each parsed once, and where their relationships lead. */
27
+ export class Presentation {
28
+ parsed = new Map();
29
+ zip;
30
+ constructor(zip) {
31
+ this.zip = zip;
32
+ }
33
+ /** Open `path`, failing with a plain explanation when it is missing or not a .pptx. */
34
+ static async open(path) {
35
+ if (!existsSync(path) || !statSync(path).isFile()) {
36
+ throw new Error(`${path} does not exist or is not a file.`);
37
+ }
38
+ let zip;
39
+ try {
40
+ zip = await JSZip.loadAsync(readFileSync(path));
41
+ }
42
+ catch {
43
+ throw new Error(`${path} is not a .pptx file (it is not a zip archive).`);
44
+ }
45
+ if (!zip.file(PRESENTATION_PART)) {
46
+ throw new Error(`${path} is not a .pptx file (no ${PRESENTATION_PART} inside; a .docx or .xlsx perhaps?).`);
47
+ }
48
+ return new Presentation(zip);
49
+ }
50
+ /** Every file in the package. */
51
+ get parts() {
52
+ return Object.keys(this.zip.files).filter((name) => !this.zip.files[name].dir);
53
+ }
54
+ /** The parts matching `pattern`, in number order. */
55
+ numbered(pattern) {
56
+ return this.parts
57
+ .filter((part) => pattern.test(part))
58
+ .sort((a, b) => partNumber(a, pattern) - partNumber(b, pattern));
59
+ }
60
+ has(part) {
61
+ return this.zip.file(part) !== null;
62
+ }
63
+ async bytes(part) {
64
+ return this.file(part).async("nodebuffer");
65
+ }
66
+ /** The part's root element. */
67
+ async xml(part) {
68
+ let doc = this.parsed.get(part);
69
+ if (!doc) {
70
+ doc = parseXml(await this.file(part).async("string"));
71
+ this.parsed.set(part, doc);
72
+ }
73
+ return doc.documentElement;
74
+ }
75
+ file(part) {
76
+ const file = this.zip.file(part);
77
+ if (!file)
78
+ throw new Error(`${part} is missing from the package.`);
79
+ return file;
80
+ }
81
+ /** The part's relationships to other parts (external links left out), with targets resolved. */
82
+ async relationships(part) {
83
+ const rels = this.zip.file(relsPathFor(part));
84
+ if (!rels)
85
+ return [];
86
+ return readRelationships(await rels.async("string"))
87
+ .filter((rel) => !rel.external)
88
+ .map((rel) => ({ ...rel, target: resolveTarget(part, rel.target) }));
89
+ }
90
+ /** The parts `part` relates to with a relationship type ending in `suffix`. */
91
+ async related(part, suffix) {
92
+ return (await this.relationships(part)).filter((rel) => rel.type.endsWith(suffix)).map((rel) => rel.target);
93
+ }
94
+ }
95
+ /** The element reached from `el` by a path of child tag names, or undefined. */
96
+ export function find(el, ...path) {
97
+ let node = el;
98
+ for (const tag of path) {
99
+ node = node && childrenByTag(node, tag)[0];
100
+ }
101
+ return node;
102
+ }
103
+ /** `el`'s element children, only those named `tag` when given; none when `el` is missing. */
104
+ export function elementChildren(el, tag) {
105
+ if (!el)
106
+ return [];
107
+ if (tag)
108
+ return childrenByTag(el, tag);
109
+ const out = [];
110
+ for (let i = 0; i < el.childNodes.length; i++) {
111
+ if (el.childNodes[i].nodeType === ELEMENT_NODE)
112
+ out.push(el.childNodes[i]);
113
+ }
114
+ return out;
115
+ }
116
+ /** `el`'s attributes by name; none when `el` is missing. */
117
+ export function attributes(el) {
118
+ const out = {};
119
+ for (let i = 0; i < (el?.attributes?.length ?? 0); i++)
120
+ out[el.attributes[i].name] = el.attributes[i].value;
121
+ return out;
122
+ }
123
+ const ELEMENT_NODE = 1;
@@ -0,0 +1,43 @@
1
+ import { type CompilerThemeConfig } from "../index.js";
2
+ import type { AssetCatalog } from "./catalog.js";
3
+ /**
4
+ * Pack `paths` (theme-relative, POSIX) into one archive, reading each through
5
+ * `read`. Entries keep their declared paths, so expanding reproduces the layout
6
+ * `theme.json` already refers to.
7
+ */
8
+ export declare function packAssets(paths: string[], read: (rel: string) => Buffer): Promise<Buffer>;
9
+ /**
10
+ * Rewrite the `name:` value in a SKILL.md's leading YAML frontmatter so the
11
+ * packaged skill is named after the consuming theme, not the source template.
12
+ * Only the leading `---`…`---` block is touched; the body is left byte-for-byte.
13
+ * Throws if there is no frontmatter or no `name:` line — the caller names the file.
14
+ */
15
+ export declare function renameSkill(md: string, name: string): string;
16
+ /**
17
+ * The `package.json` a packaged skill installs from — deliberately NOT the theme's
18
+ * own. A theme repo's manifest is a development document: it may carry scripts,
19
+ * and lists the engine as a devDependency because the repo builds with it rather
20
+ * than shipping it.
21
+ *
22
+ * Copying that verbatim breaks the consumer twice. An install script runs inside
23
+ * their container, so anything it touches that is read-only fails their whole
24
+ * `npm install`. And under `--omit=dev` the engine is never installed, so
25
+ * `npx tycoslide build` cannot find it.
26
+ *
27
+ * What ships instead declares only what the skill needs to RUN: the theme's own
28
+ * dependencies plus the engine, as runtime dependencies, and no scripts at all.
29
+ */
30
+ export declare function skillPackageJson(theme: Record<string, unknown>, engine: {
31
+ name: string;
32
+ version: string;
33
+ }): string;
34
+ /**
35
+ * Zip a theme into an uploadable Agent Skill archive whose entries all live
36
+ * under a single root folder (e.g. `acme-slides/theme.json`), matching Anthropic's
37
+ * custom-skill format. `themeFiles` names the theme's own top-level files that go
38
+ * in as they are (the config and the catalog); `generated` is the skill's files
39
+ * that exist only in the zip (its `package.json`, `SKILL.md` and manifest), by
40
+ * name. Optional support files are skipped when absent; anything the config or
41
+ * catalog declares but that is missing is an error.
42
+ */
43
+ export declare function zipDir(rootDir: string, folderName: string, config: CompilerThemeConfig, catalog: AssetCatalog, themeFiles: string[], generated: Record<string, string>): Promise<Buffer>;
@@ -0,0 +1,134 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import JSZip from "jszip";
4
+ import { TEMPLATE_DIR } from "../index.js";
5
+ import { ASSETS_ARCHIVE, jsonFile } from "./files.js";
6
+ /** Entries are stored, not deflated: assets are already-compressed images. */
7
+ const NO_COMPRESSION = { type: "nodebuffer", compression: "STORE" };
8
+ /**
9
+ * Pack `paths` (theme-relative, POSIX) into one archive, reading each through
10
+ * `read`. Entries keep their declared paths, so expanding reproduces the layout
11
+ * `theme.json` already refers to.
12
+ */
13
+ export async function packAssets(paths, read) {
14
+ const archive = new JSZip();
15
+ for (const rel of paths)
16
+ archive.file(rel, read(rel));
17
+ return archive.generateAsync(NO_COMPRESSION);
18
+ }
19
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---/;
20
+ const NAME_LINE = /^name:[ \t]*.*$/m;
21
+ /**
22
+ * Rewrite the `name:` value in a SKILL.md's leading YAML frontmatter so the
23
+ * packaged skill is named after the consuming theme, not the source template.
24
+ * Only the leading `---`…`---` block is touched; the body is left byte-for-byte.
25
+ * Throws if there is no frontmatter or no `name:` line — the caller names the file.
26
+ */
27
+ export function renameSkill(md, name) {
28
+ const block = md.match(FRONTMATTER);
29
+ if (!block)
30
+ throw new Error("SKILL.md has no YAML frontmatter block");
31
+ if (!NAME_LINE.test(block[1]))
32
+ throw new Error('SKILL.md frontmatter has no "name:" line');
33
+ return md.replace(block[0], block[0].replace(NAME_LINE, `name: ${name}`));
34
+ }
35
+ /**
36
+ * Files a packaged skill needs beyond the theme's own declarations. Only the
37
+ * lockfile: `package.json` is authored by `skillPackageJson` rather than taken
38
+ * from the theme directory.
39
+ */
40
+ const SUPPORT_FILES = ["package-lock.json"];
41
+ /**
42
+ * The `package.json` a packaged skill installs from — deliberately NOT the theme's
43
+ * own. A theme repo's manifest is a development document: it may carry scripts,
44
+ * and lists the engine as a devDependency because the repo builds with it rather
45
+ * than shipping it.
46
+ *
47
+ * Copying that verbatim breaks the consumer twice. An install script runs inside
48
+ * their container, so anything it touches that is read-only fails their whole
49
+ * `npm install`. And under `--omit=dev` the engine is never installed, so
50
+ * `npx tycoslide build` cannot find it.
51
+ *
52
+ * What ships instead declares only what the skill needs to RUN: the theme's own
53
+ * dependencies plus the engine, as runtime dependencies, and no scripts at all.
54
+ */
55
+ export function skillPackageJson(theme, engine) {
56
+ const dependencies = {
57
+ ...(theme.dependencies ?? {}),
58
+ [engine.name]: `^${engine.version}`,
59
+ };
60
+ const skill = {
61
+ name: theme.name,
62
+ version: theme.version,
63
+ description: theme.description,
64
+ private: true,
65
+ dependencies: Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b))),
66
+ };
67
+ return jsonFile(skill);
68
+ }
69
+ /**
70
+ * Every path a packaged theme needs, relative to `rootDir` and POSIX-separated,
71
+ * split by how it ships.
72
+ *
73
+ * Derived from the theme config and its image catalog rather than filtered out
74
+ * of a directory walk: together they declare the template and every image, so
75
+ * an allowlist stays correct no matter what else sits in the working directory --
76
+ * built decks, PDFs, slide PNGs, scratch files. Font paths are deliberately
77
+ * absent when they name a package -- those resolve from node_modules, which
78
+ * `npm install` restores -- but a `./`- or `/`-prefixed font path is a file the
79
+ * theme owns, and mermaid reads it during a build, which never expands the
80
+ * archive. Those ship plain.
81
+ *
82
+ * `archived` is the catalog's images, which collapse to one archive because
83
+ * hosts cap how many FILES a skill may contain. `plain` is everything else,
84
+ * including the catalog itself.
85
+ */
86
+ function skillPaths(config, catalog, themeFiles) {
87
+ const archived = Object.values(catalog).flatMap((category) => Object.values(category).map((entry) => entry.path));
88
+ const localFonts = (config.fonts ?? []).map((f) => f.path).filter((p) => p.startsWith(".") || p.startsWith("/"));
89
+ return {
90
+ plain: [...SUPPORT_FILES, ...themeFiles, `${TEMPLATE_DIR}/${config.template}`, ...localFonts],
91
+ archived,
92
+ };
93
+ }
94
+ /**
95
+ * Zip a theme into an uploadable Agent Skill archive whose entries all live
96
+ * under a single root folder (e.g. `acme-slides/theme.json`), matching Anthropic's
97
+ * custom-skill format. `themeFiles` names the theme's own top-level files that go
98
+ * in as they are (the config and the catalog); `generated` is the skill's files
99
+ * that exist only in the zip (its `package.json`, `SKILL.md` and manifest), by
100
+ * name. Optional support files are skipped when absent; anything the config or
101
+ * catalog declares but that is missing is an error.
102
+ */
103
+ export async function zipDir(rootDir, folderName, config, catalog, themeFiles, generated) {
104
+ const zip = new JSZip();
105
+ const folder = zip.folder(folderName);
106
+ if (!folder)
107
+ throw new Error(`Failed to create zip folder: ${folderName}`);
108
+ for (const [name, content] of Object.entries(generated))
109
+ folder.file(name, content);
110
+ const { plain, archived } = skillPaths(config, catalog, themeFiles);
111
+ const optional = new Set(SUPPORT_FILES);
112
+ // `optional` is a plain-bucket concept (a lockfile a theme may not have). An
113
+ // asset the catalog declares is never optional, so the archived loop calls
114
+ // `required` and a missing one throws rather than silently vanishing.
115
+ const required = (rel) => {
116
+ const abs = join(rootDir, ...rel.split("/"));
117
+ if (existsSync(abs))
118
+ return readFileSync(abs);
119
+ throw new Error(`Theme declares "${rel}", but no such file exists`);
120
+ };
121
+ const read = (rel) => {
122
+ if (optional.has(rel) && !existsSync(join(rootDir, ...rel.split("/"))))
123
+ return null;
124
+ return required(rel);
125
+ };
126
+ for (const rel of plain) {
127
+ const content = read(rel);
128
+ if (content !== null)
129
+ folder.file(rel, content);
130
+ }
131
+ if (archived.length > 0)
132
+ folder.file(ASSETS_ARCHIVE, await packAssets(archived, required));
133
+ return zip.generateAsync(NO_COMPRESSION);
134
+ }
package/dist/cli.js CHANGED
@@ -1,16 +1,15 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
2
  import { basename, dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { Command } from "commander";
5
- import { ASSETS_FILE, MANIFEST_FILE, SKILL_FILE, SYNTAX_FILE, THEME_CONFIG, THEME_PACKAGE_DIR } from "./files.js";
5
+ import { registerAgentCommands } from "./agents/commands.js";
6
+ import { PACKAGE_JSON } from "./agents/files.js";
6
7
  import { buildDeck } from "./index.js";
7
- import { generateAssetCatalog, generateManifest } from "./manifest.js";
8
8
  import { compileDeck, loadThemeConfig, parseSlideDocument, RESERVED_KEY } from "./markdown/index.js";
9
- import { renameSkill, skillPackageJson, zipDir } from "./skillZip.js";
10
- const sdkDir = dirname(fileURLToPath(import.meta.url));
11
- const skillMdPath = resolve(sdkDir, "..", THEME_PACKAGE_DIR, SKILL_FILE);
12
- const syntaxMdPath = resolve(sdkDir, "..", THEME_PACKAGE_DIR, SYNTAX_FILE);
13
- const pkg = JSON.parse(readFileSync(resolve(sdkDir, "..", "package.json"), "utf-8"));
9
+ // The CLI is the one module that wires both layers: `build` from the core, and
10
+ // the agent layer's commands registered onto the same program.
11
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12
+ const pkg = JSON.parse(readFileSync(resolve(packageRoot, PACKAGE_JSON), "utf-8"));
14
13
  const program = new Command().name("tycoslide").description("PPTX template engine CLI").version(pkg.version);
15
14
  program
16
15
  .command("build")
@@ -45,42 +44,7 @@ program
45
44
  deck.output = resolve(dirname(absDeckPath), outName);
46
45
  await buildDeck(deck, config, { excludeNotes: !opts.notes });
47
46
  });
48
- program
49
- .command("package")
50
- .description("Generate the Agent Skill (manifest.json, SKILL.md, syntax.md) for AI agents")
51
- .option(`-c, --config <path>`, "path to theme config file", THEME_CONFIG)
52
- .action(async (opts) => {
53
- const config = loadThemeConfig(resolve(process.cwd(), opts.config));
54
- const themePkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
55
- if (!themePkg.name) {
56
- throw new Error('Cannot name the skill: the theme\'s package.json has no "name" field.');
57
- }
58
- // basename drops any npm scope, e.g. "@acme/acme-slides" -> "acme-slides".
59
- const skillName = basename(themePkg.name);
60
- writeFileSync(resolve(process.cwd(), MANIFEST_FILE), `${generateManifest(config)}\n`);
61
- console.log(`WROTE ${MANIFEST_FILE}`);
62
- writeFileSync(resolve(process.cwd(), ASSETS_FILE), `${generateAssetCatalog(config)}\n`);
63
- console.log(`WROTE ${ASSETS_FILE}`);
64
- let skillMd;
65
- try {
66
- skillMd = renameSkill(readFileSync(skillMdPath, "utf-8"), skillName);
67
- }
68
- catch (err) {
69
- throw new Error(`${skillMdPath}: ${err.message}`);
70
- }
71
- writeFileSync(resolve(process.cwd(), SKILL_FILE), skillMd);
72
- console.log(`WROTE ${SKILL_FILE}`);
73
- const syntaxMd = readFileSync(syntaxMdPath, "utf-8");
74
- writeFileSync(resolve(process.cwd(), SYNTAX_FILE), syntaxMd);
75
- console.log(`WROTE ${SYNTAX_FILE}`);
76
- // Bundle the WHOLE theme so the skill is self-contained: unzip ->
77
- // `npm install` (pulls the engine + its deps) -> `npx tycoslide build`.
78
- const zipFile = `${skillName}.zip`;
79
- const generated = [opts.config, MANIFEST_FILE, ASSETS_FILE, SKILL_FILE, SYNTAX_FILE];
80
- const skillPkg = skillPackageJson(themePkg, { name: pkg.name, version: pkg.version });
81
- writeFileSync(resolve(process.cwd(), zipFile), await zipDir(process.cwd(), skillName, config, generated, skillPkg));
82
- console.log(`WROTE ${zipFile}`);
83
- });
47
+ registerAgentCommands(program, { root: packageRoot, name: pkg.name, version: pkg.version });
84
48
  // Everything below the CLI throws plain Errors carrying a written-for-humans
85
49
  // message. Print that message and stop; a Node stack trace tells a deck author
86
50
  // nothing about their deck, and buries the part that does.
@@ -28,6 +28,7 @@ export declare const Tag: {
28
28
  readonly BLIP: "a:blip";
29
29
  readonly SRC_RECT: "a:srcRect";
30
30
  readonly BLIP_FILL: "p:blipFill";
31
+ readonly NON_VISUAL_PROPS: "p:cNvPr";
31
32
  readonly SPACE_BEFORE: "a:spcBef";
32
33
  readonly HLINK_CLICK: "a:hlinkClick";
33
34
  readonly RELATIONSHIP: "Relationship";
@@ -70,6 +71,8 @@ export declare const Attr: {
70
71
  readonly TOP: "t";
71
72
  readonly RIGHT: "r";
72
73
  readonly BOTTOM: "b";
74
+ readonly DESCR: "descr";
75
+ readonly TITLE: "title";
73
76
  readonly XML_SPACE: "xml:space";
74
77
  readonly PRESERVE: "preserve";
75
78
  };
@@ -28,6 +28,7 @@ export const Tag = {
28
28
  BLIP: "a:blip",
29
29
  SRC_RECT: "a:srcRect",
30
30
  BLIP_FILL: "p:blipFill",
31
+ NON_VISUAL_PROPS: "p:cNvPr",
31
32
  SPACE_BEFORE: "a:spcBef",
32
33
  HLINK_CLICK: "a:hlinkClick",
33
34
  RELATIONSHIP: "Relationship",
@@ -77,6 +78,9 @@ export const Attr = {
77
78
  TOP: "t",
78
79
  RIGHT: "r",
79
80
  BOTTOM: "b",
81
+ // non-visual properties (alt text)
82
+ DESCR: "descr",
83
+ TITLE: "title",
80
84
  // whitespace preservation
81
85
  XML_SPACE: "xml:space",
82
86
  PRESERVE: "preserve",
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Image fill — element-level picture geometry only. The media swap (pointing the
3
- * blip relationship at the new file) is a slide-level modifier in the ImageFiller
4
- * (`fillers/filler.ts`); this module resolves the frame geometry from the image's
5
- * `ImageFit` (contain/cover/scale-down) and the source's true pixel size.
2
+ * Image fill — element-level picture geometry and alt text. The media swap
3
+ * (pointing the blip relationship at the new file) is a slide-level modifier in
4
+ * the ImageFiller (`fillers/filler.ts`); this module resolves the frame geometry
5
+ * from the image's `ImageFit` (contain/cover/scale-down) and the source's true
6
+ * pixel size, and rewrites the picture's alt text for the new image.
6
7
  */
7
8
  import { type ImageFill, ImageFit } from "../types.js";
8
9
  /** A picture frame's placement + size, in EMU. */
@@ -37,7 +38,7 @@ export type GeometryResult = {
37
38
  * Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
38
39
  * `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
39
40
  * aspect ratio and re-center (fit/letterbox). Advisory warnings from the
40
- * geometry pass go to `console.warn`.
41
+ * geometry pass go to `console.warn`. Also sets its alt text (`setAltText`).
41
42
  *
42
43
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
43
44
  * ImageFill reaches the engine.
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Image fill — element-level picture geometry only. The media swap (pointing the
3
- * blip relationship at the new file) is a slide-level modifier in the ImageFiller
4
- * (`fillers/filler.ts`); this module resolves the frame geometry from the image's
5
- * `ImageFit` (contain/cover/scale-down) and the source's true pixel size.
2
+ * Image fill — element-level picture geometry and alt text. The media swap
3
+ * (pointing the blip relationship at the new file) is a slide-level modifier in
4
+ * the ImageFiller (`fillers/filler.ts`); this module resolves the frame geometry
5
+ * from the image's `ImageFit` (contain/cover/scale-down) and the source's true
6
+ * pixel size, and rewrites the picture's alt text for the new image.
6
7
  */
7
8
  import { readFileSync } from "node:fs";
8
9
  import { imageSize } from "image-size";
@@ -25,7 +26,7 @@ const MIN_SCALE = 0.2;
25
26
  * Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
26
27
  * `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
27
28
  * aspect ratio and re-center (fit/letterbox). Advisory warnings from the
28
- * geometry pass go to `console.warn`.
29
+ * geometry pass go to `console.warn`. Also sets its alt text (`setAltText`).
29
30
  *
30
31
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
31
32
  * ImageFill reaches the engine.
@@ -38,9 +39,11 @@ export function fillImage(shape, image, shapeName, label) {
38
39
  const off = shape.getElementsByTagName(Tag.OFFSET)[0];
39
40
  const ext = shape.getElementsByTagName(Tag.EXTENT)[0];
40
41
  const blipFill = shape.getElementsByTagName(Tag.BLIP_FILL)[0];
41
- if (!off || !ext || !blipFill) {
42
- throw new Error(`Image shape "${shapeName}": is not a picture (missing <a:off>, <a:ext>, or <p:blipFill>).`);
42
+ const nvProps = shape.getElementsByTagName(Tag.NON_VISUAL_PROPS)[0];
43
+ if (!off || !ext || !blipFill || !nvProps) {
44
+ throw new Error(`Image shape "${shapeName}": is not a picture (missing <a:off>, <a:ext>, <p:blipFill>, or <p:cNvPr>).`);
43
45
  }
46
+ setAltText(nvProps, image.alt);
44
47
  const frame = {
45
48
  x: Number(off.getAttribute(Attr.X)),
46
49
  y: Number(off.getAttribute(Attr.Y)),
@@ -141,6 +144,20 @@ function applySrcRect(shape, blipFill, l, t, r, b) {
141
144
  srcRect.setAttribute(Attr.RIGHT, String(r));
142
145
  srcRect.setAttribute(Attr.BOTTOM, String(b));
143
146
  }
147
+ /**
148
+ * Describe the new picture on its `<p:cNvPr>`: `descr` carries the alt text, or
149
+ * is removed when it is empty, since any description the placeholder carried
150
+ * was for a different image. The legacy `title` is always removed: on a
151
+ * template placeholder it is a stale label (often the source filename), and
152
+ * `descr` alone is what PowerPoint shows and reads.
153
+ */
154
+ function setAltText(nvProps, alt) {
155
+ nvProps.removeAttribute(Attr.TITLE);
156
+ if (alt)
157
+ nvProps.setAttribute(Attr.DESCR, alt);
158
+ else
159
+ nvProps.removeAttribute(Attr.DESCR);
160
+ }
144
161
  /** Discriminator for ImageFill values. */
145
162
  export function isImageFill(v) {
146
163
  return (isPlainObject(v) &&
@@ -15,8 +15,8 @@
15
15
  * fills each cell's first paragraph. See fillers/table.ts.
16
16
  *
17
17
  * ImageFill → fillImage Points the picture's blip relationship at a new media
18
- * file (registered by generate(), swapped by the ImageFiller) and adjusts
19
- * geometry for the chosen fit. See fillers/image.ts.
18
+ * file (registered by generate(), swapped by the ImageFiller), adjusts
19
+ * geometry for the chosen fit, and sets its alt text. See fillers/image.ts.
20
20
  *
21
21
  * generate() loads the template, registers media, and for each DeckStep clones
22
22
  * the layout's base slide and calls `fillSlide`. Each value in `step.content`
@@ -15,8 +15,8 @@
15
15
  * fills each cell's first paragraph. See fillers/table.ts.
16
16
  *
17
17
  * ImageFill → fillImage Points the picture's blip relationship at a new media
18
- * file (registered by generate(), swapped by the ImageFiller) and adjusts
19
- * geometry for the chosen fit. See fillers/image.ts.
18
+ * file (registered by generate(), swapped by the ImageFiller), adjusts
19
+ * geometry for the chosen fit, and sets its alt text. See fillers/image.ts.
20
20
  *
21
21
  * generate() loads the template, registers media, and for each DeckStep clones
22
22
  * the layout's base slide and calls `fillSlide`. Each value in `step.content`
@@ -1,3 +1,4 @@
1
+ export { childrenByTag, collectElements } from "./dom.js";
1
2
  export { FILLERS } from "./fillers/filler.js";
2
3
  export { fillImage } from "./fillers/image.js";
3
4
  export { fillTable, isTableFill } from "./fillers/table.js";
@@ -5,5 +6,7 @@ export { fillTemplate } from "./fillers/template.js";
5
6
  export { fillText, isTextFill } from "./fillers/text.js";
6
7
  export type { GenerateOptions } from "./generate.js";
7
8
  export { generate } from "./generate.js";
9
+ export type { Relationship } from "./ooxml.js";
10
+ export { PRESENTATION_PART, parseXml, readRelationships, relsPathFor, resolveTarget } from "./ooxml.js";
8
11
  export type { Block, BodyRows, Config, Deck, DeckStep, Frame, ImageFill, Layout, Slot, StyledParagraph, TableFill, TemplateFill, TemplateSegment, TextFill, TextRun, ThemeConfig, } from "./types.js";
9
12
  export { ImageFit, SlotType, TEMPLATE_DIR } from "./types.js";