@tycoworks/tycoslide 0.15.1 → 0.16.1
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.
- package/README.md +9 -5
- package/dist/agents/catalog.d.ts +32 -0
- package/dist/agents/catalog.js +37 -0
- package/dist/agents/commands.d.ts +9 -0
- package/dist/agents/commands.js +57 -0
- package/dist/agents/extract.d.ts +22 -0
- package/dist/agents/extract.js +54 -0
- package/dist/{files.d.ts → agents/files.d.ts} +14 -3
- package/dist/{files.js → agents/files.js} +20 -5
- package/dist/agents/inventory.d.ts +83 -0
- package/dist/agents/inventory.js +568 -0
- package/dist/agents/manifest.d.ts +3 -0
- package/dist/{manifest.js → agents/manifest.js} +3 -14
- package/dist/agents/media.d.ts +38 -0
- package/dist/agents/media.js +83 -0
- package/dist/agents/pptx.d.ts +48 -0
- package/dist/agents/pptx.js +123 -0
- package/dist/agents/skill.d.ts +43 -0
- package/dist/agents/skill.js +134 -0
- package/dist/cli.js +8 -44
- package/dist/engine/dom.d.ts +3 -0
- package/dist/engine/dom.js +4 -0
- package/dist/engine/fillers/image.d.ts +6 -5
- package/dist/engine/fillers/image.js +24 -7
- package/dist/engine/generate.d.ts +2 -2
- package/dist/engine/generate.js +2 -2
- package/dist/engine/index.d.ts +3 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/notes.js +20 -62
- package/dist/engine/ooxml.d.ts +36 -0
- package/dist/engine/ooxml.js +56 -0
- package/dist/engine/types.d.ts +4 -0
- package/dist/index.d.ts +4 -11
- package/dist/index.js +2 -15
- package/dist/markdown/blocks/code.js +1 -2
- package/dist/markdown/blocks/image.d.ts +23 -0
- package/dist/markdown/blocks/image.js +65 -3
- package/dist/markdown/blocks/mermaid.d.ts +1 -1
- package/dist/markdown/blocks/mermaid.js +6 -4
- package/dist/markdown/blocks/table.js +7 -6
- package/dist/markdown/blocks/text.js +9 -8
- package/dist/markdown/deckCompiler.d.ts +2 -13
- package/dist/markdown/deckCompiler.js +7 -78
- package/dist/markdown/index.d.ts +4 -2
- package/dist/markdown/index.js +3 -1
- package/dist/markdown/inline.d.ts +2 -0
- package/dist/markdown/inline.js +4 -3
- package/dist/markdown/schema/themeConfigSchema.d.ts +2 -11
- package/dist/markdown/schema/themeConfigSchema.js +6 -16
- package/dist/markdown/slideParser.d.ts +2 -0
- package/dist/markdown/slideParser.js +6 -3
- package/dist/markdown/types.d.ts +4 -29
- package/dist/markdown/types.js +0 -11
- package/{theme-package/syntax.md → docs/markdown.md} +60 -28
- package/docs/theme.md +144 -0
- package/package.json +5 -4
- package/theme-package/SKILL.template.md +161 -0
- package/dist/engine/rels.d.ts +0 -18
- package/dist/engine/rels.js +0 -55
- package/dist/manifest.d.ts +0 -5
- package/dist/skillZip.d.ts +0 -49
- package/dist/skillZip.js +0 -177
- package/theme-package/SKILL.md +0 -226
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
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.
|
package/dist/engine/dom.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/engine/dom.js
CHANGED
|
@@ -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
|
|
3
|
-
* blip relationship at the new file) is a slide-level modifier in
|
|
4
|
-
* (`fillers/filler.ts`); this module resolves the frame geometry
|
|
5
|
-
* `ImageFit` (contain/cover/scale-down) and the source's true
|
|
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
|
|
3
|
-
* blip relationship at the new file) is a slide-level modifier in
|
|
4
|
-
* (`fillers/filler.ts`); this module resolves the frame geometry
|
|
5
|
-
* `ImageFit` (contain/cover/scale-down) and the source's true
|
|
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
|
-
|
|
42
|
-
|
|
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)
|
|
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`
|
package/dist/engine/generate.js
CHANGED
|
@@ -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)
|
|
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`
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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";
|