mulmocast 2.11.0 → 2.12.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.
- package/assets/schemas/mulmo_script.json +15431 -190
- package/lib/actions/html.js +22 -4
- package/lib/actions/markdown.js +5 -2
- package/lib/mcp/server.js +5 -3
- package/lib/utils/beat_html/type.d.ts +8 -1
- package/lib/utils/html_escape.d.ts +2 -0
- package/lib/utils/html_escape.js +16 -0
- package/lib/utils/image_plugins/bg_image_util.js +5 -3
- package/lib/utils/image_plugins/slide.js +21 -2
- package/lib/utils/swipe_to_html.d.ts +7 -0
- package/lib/utils/swipe_to_html.js +8 -1
- package/package.json +3 -1
package/lib/actions/html.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { isNull } from "graphai";
|
|
4
|
+
import { escapeHtml, slideUtilityCss } from "@mulmocast/deck";
|
|
4
5
|
import { localizedText } from "../utils/utils.js";
|
|
5
6
|
import { writingMessage } from "../utils/file.js";
|
|
6
7
|
import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
|
|
8
|
+
/**
|
|
9
|
+
* The HTML document for a studio.
|
|
10
|
+
*
|
|
11
|
+
* Every value that reaches an attribute or `<title>` comes from the script, and a beat with a
|
|
12
|
+
* `source: { kind: "path" }` image puts the author's own path into `src`, so each one is
|
|
13
|
+
* escaped for the context it lands in.
|
|
14
|
+
*
|
|
15
|
+
* Not exported: `actions/index.ts` re-exports this module with `export *` and `index.node.ts`
|
|
16
|
+
* re-exports that, so anything exported here becomes public API of the published package. The
|
|
17
|
+
* tests drive it through `html()` instead.
|
|
18
|
+
*/
|
|
7
19
|
const generateHtmlContent = (context, imageWidth) => {
|
|
8
20
|
const { studio, multiLingual, lang = "en" } = context;
|
|
9
21
|
const title = studio.script.title || "MulmoCast Content";
|
|
@@ -20,11 +32,14 @@ const generateHtmlContent = (context, imageWidth) => {
|
|
|
20
32
|
html += `${studioBeat.html}\n\n`;
|
|
21
33
|
}
|
|
22
34
|
else if (studioBeat?.imageFile && isNull(studioBeat.html)) {
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
// A `source: { kind: "path" }` beat puts the author's own path here, so it reaches an
|
|
36
|
+
// HTML attribute unfiltered unless it is escaped. Every value in these attributes goes
|
|
37
|
+
// through the same rule rather than each being argued safe on its own.
|
|
38
|
+
const imagePath = escapeHtml(path.relative(context.fileDirs.outDirPath, studioBeat.imageFile));
|
|
39
|
+
const altText = escapeHtml(`Beat ${index + 1}`);
|
|
25
40
|
if (imageWidth) {
|
|
26
41
|
// Use HTML img tag for width control
|
|
27
|
-
html += `<img src="${imagePath}" alt="${altText}" width="${imageWidth}" />\n\n`;
|
|
42
|
+
html += `<img src="${imagePath}" alt="${altText}" width="${escapeHtml(imageWidth)}" />\n\n`;
|
|
28
43
|
}
|
|
29
44
|
else {
|
|
30
45
|
// Use standard html image syntax
|
|
@@ -42,13 +57,16 @@ const generateHtmlContent = (context, imageWidth) => {
|
|
|
42
57
|
<head>
|
|
43
58
|
<meta charset="UTF-8" />
|
|
44
59
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
45
|
-
<title>${title}</title>
|
|
60
|
+
<title>${escapeHtml(title)}</title>
|
|
46
61
|
<!-- Tailwind CSS CDN -->
|
|
47
62
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
48
63
|
<!-- Chart.js CDN -->
|
|
49
64
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
50
65
|
<!-- Mermaid CDN -->
|
|
51
66
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
67
|
+
<!-- The utilities every slide fragment is written against. Shared, so it belongs to the
|
|
68
|
+
page: a slide beat emits only its own scoped rules. -->
|
|
69
|
+
<style>${slideUtilityCss}</style>
|
|
52
70
|
</head>
|
|
53
71
|
<body class="min-h-screen flex flex-col">
|
|
54
72
|
${html}
|
package/lib/actions/markdown.js
CHANGED
|
@@ -3,6 +3,7 @@ import { localizedText } from "../utils/utils.js";
|
|
|
3
3
|
import { writingMessage } from "../utils/file.js";
|
|
4
4
|
import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
|
|
5
5
|
import path from "path";
|
|
6
|
+
import { escapeHtml } from "@mulmocast/deck";
|
|
6
7
|
const generateMarkdownContent = (context, imageWidth) => {
|
|
7
8
|
const { studio, multiLingual, lang = "en" } = context;
|
|
8
9
|
const title = studio.script.title || "MulmoCast Content";
|
|
@@ -21,9 +22,11 @@ const generateMarkdownContent = (context, imageWidth) => {
|
|
|
21
22
|
else if (studioBeat?.imageFile) {
|
|
22
23
|
const imagePath = path.relative(context.fileDirs.outDirPath, studioBeat.imageFile);
|
|
23
24
|
if (imageWidth) {
|
|
24
|
-
//
|
|
25
|
+
// An HTML tag is HTML wherever the markdown is rendered, and a
|
|
26
|
+
// `source: { kind: "path" }` beat puts the author's own path here — the same shape
|
|
27
|
+
// and the same input as the `<img>` in actions/html.ts.
|
|
25
28
|
const altText = `Beat ${index + 1}`;
|
|
26
|
-
markdown += `<img src="${imagePath}" alt="${altText}" width="${imageWidth}" />\n\n`;
|
|
29
|
+
markdown += `<img src="${escapeHtml(imagePath)}" alt="${escapeHtml(altText)}" width="${escapeHtml(imageWidth)}" />\n\n`;
|
|
27
30
|
}
|
|
28
31
|
else {
|
|
29
32
|
// Use standard markdown image syntax
|
package/lib/mcp/server.js
CHANGED
|
@@ -15,8 +15,10 @@ import { MulmoScriptMethods } from "../methods/index.js";
|
|
|
15
15
|
dotenv.config({ quiet: true });
|
|
16
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
17
|
const __dirname = path.dirname(__filename);
|
|
18
|
-
//
|
|
19
|
-
|
|
18
|
+
// The `mulmoScript` argument's shape, as advertised to an MCP client. It is html_prompt.json,
|
|
19
|
+
// not mulmo_script.json — the switch was deliberate, but the old name outlived it and is why
|
|
20
|
+
// nobody noticed that mulmo_script.json had no reader left for fourteen months.
|
|
21
|
+
const HTML_PROMPT_JSON_SCHEMA = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../assets/schemas/html_prompt.json"), "utf8"));
|
|
20
22
|
const server = new Server({
|
|
21
23
|
name: "mulmocast-mcp",
|
|
22
24
|
version: "0.1.0",
|
|
@@ -53,7 +55,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
53
55
|
enum: ["movie", "pdf"],
|
|
54
56
|
description: "Command to execute: 'movie' to generate video, 'pdf' to generate PDF",
|
|
55
57
|
},
|
|
56
|
-
mulmoScript:
|
|
58
|
+
mulmoScript: HTML_PROMPT_JSON_SCHEMA,
|
|
57
59
|
options: {
|
|
58
60
|
type: "object",
|
|
59
61
|
description: "Optional generation parameters",
|
|
@@ -21,7 +21,14 @@ export type BeatHtmlFragment = {
|
|
|
21
21
|
* since it has no DOM and must stay dependency-light to remain bundleable.
|
|
22
22
|
*/
|
|
23
23
|
html: string;
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Rules the markup needs, scoped by the caller. Absent when the markup needs none.
|
|
26
|
+
*
|
|
27
|
+
* It cannot carry everything: an `html_tailwind` beat written with `elements` is a root
|
|
28
|
+
* asking for 100% of its parent, and how tall that parent should be is the host's layout,
|
|
29
|
+
* not the beat's. The root is marked `data-mulmo-swipe-root` so a host can give it one —
|
|
30
|
+
* and a host that does not gets an empty box, silently.
|
|
31
|
+
*/
|
|
25
32
|
css?: string;
|
|
26
33
|
/** External runtimes the host must load once for the page, not once per beat. */
|
|
27
34
|
requires?: BeatRuntime[];
|
|
@@ -11,4 +11,6 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/** Neutralize the output of JSON.stringify for embedding inside an inline `<script>`. */
|
|
13
13
|
export declare const escapeJsonForScript: (json: string) => string;
|
|
14
|
+
/** Neutralize a value placed inside a quoted CSS string, such as the argument of `url()`. */
|
|
15
|
+
export declare const escapeCssString: (value: string) => string;
|
|
14
16
|
export declare const neutralizeStyleTerminator: (css: string) => string;
|
package/lib/utils/html_escape.js
CHANGED
|
@@ -22,6 +22,22 @@ const SCRIPT_JSON_ESCAPES = new Map([
|
|
|
22
22
|
]);
|
|
23
23
|
/** Neutralize the output of JSON.stringify for embedding inside an inline `<script>`. */
|
|
24
24
|
export const escapeJsonForScript = (json) => json.replace(/[<>&\u2028\u2029]/g, (character) => SCRIPT_JSON_ESCAPES.get(character) ?? character);
|
|
25
|
+
// A CSS string ends at the first unescaped quote of the kind that opened it, and a raw
|
|
26
|
+
// newline ends the declaration outright, so a value carrying either escapes the string it
|
|
27
|
+
// was placed in. Backslash is what CSS defines for both; a line break becomes its hex escape
|
|
28
|
+
// with the terminating space CSS requires. Both quote characters are escaped so a caller may
|
|
29
|
+
// use either, and every other character — including the base64 body of a data URL — is left
|
|
30
|
+
// exactly as it was.
|
|
31
|
+
const CSS_STRING_ESCAPES = new Map([
|
|
32
|
+
["\\", "\\\\"],
|
|
33
|
+
["'", "\\'"],
|
|
34
|
+
['"', '\\"'],
|
|
35
|
+
["\n", "\\a "],
|
|
36
|
+
["\r", "\\d "],
|
|
37
|
+
["\f", "\\c "],
|
|
38
|
+
]);
|
|
39
|
+
/** Neutralize a value placed inside a quoted CSS string, such as the argument of `url()`. */
|
|
40
|
+
export const escapeCssString = (value) => value.replace(/[\\'"\n\r\f]/g, (character) => CSS_STRING_ESCAPES.get(character) ?? character);
|
|
25
41
|
// A <style> element holds raw text: nothing inside it is parsed as markup except the closing
|
|
26
42
|
// tag, so `</style` is the only sequence that can end it. Neutralizing just that keeps
|
|
27
43
|
// author-supplied CSS working, which escaping the whole string would not. `\3c ` is the CSS
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MulmoMediaSourceMethods } from "../../methods/mulmo_media_source.js";
|
|
2
2
|
import { resolveStyle } from "./utils.js";
|
|
3
3
|
import { safeFetch, DEFAULT_FETCH_TIMEOUT_MS } from "../fetch.js";
|
|
4
|
-
import { neutralizeStyleTerminator } from "../html_escape.js";
|
|
4
|
+
import { escapeCssString, neutralizeStyleTerminator } from "../html_escape.js";
|
|
5
5
|
/**
|
|
6
6
|
* Resolve background image from beat level and global level settings.
|
|
7
7
|
* Beat level takes precedence over global level.
|
|
@@ -58,6 +58,8 @@ export const backgroundImageToCSS = async (backgroundImage, context) => {
|
|
|
58
58
|
return "";
|
|
59
59
|
}
|
|
60
60
|
const isSimpleUrl = typeof backgroundImage === "string";
|
|
61
|
+
// Escaped at each use rather than here: the base64 body cannot carry a quote, but the
|
|
62
|
+
// content-type in front of it is whatever the remote server's response header said.
|
|
61
63
|
const imageUrl = isSimpleUrl ? await fetchUrlAsDataUrl(backgroundImage) : await MulmoMediaSourceMethods.toDataUrl(backgroundImage.source, context);
|
|
62
64
|
const size = sizeToCSS(isSimpleUrl ? "cover" : (backgroundImage.size ?? "cover"));
|
|
63
65
|
const opacity = isSimpleUrl ? 1 : (backgroundImage.opacity ?? 1);
|
|
@@ -75,7 +77,7 @@ export const backgroundImageToCSS = async (backgroundImage, context) => {
|
|
|
75
77
|
left: 0;
|
|
76
78
|
right: 0;
|
|
77
79
|
bottom: 0;
|
|
78
|
-
background-image: url('${imageUrl}');
|
|
80
|
+
background-image: url('${escapeCssString(imageUrl)}');
|
|
79
81
|
background-size: ${size};
|
|
80
82
|
background-position: center;
|
|
81
83
|
background-repeat: no-repeat;
|
|
@@ -90,7 +92,7 @@ export const backgroundImageToCSS = async (backgroundImage, context) => {
|
|
|
90
92
|
margin: 0;
|
|
91
93
|
}
|
|
92
94
|
body {
|
|
93
|
-
background-image: url('${imageUrl}');
|
|
95
|
+
background-image: url('${escapeCssString(imageUrl)}');
|
|
94
96
|
background-size: ${size};
|
|
95
97
|
background-position: center;
|
|
96
98
|
background-repeat: no-repeat;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import nodePath from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { generateSlideHTML } from "@mulmocast/deck";
|
|
3
|
+
import { generateSlideFragment, generateSlideHTML } from "@mulmocast/deck";
|
|
4
4
|
import { renderHTMLToImage } from "../html_render.js";
|
|
5
5
|
import { parrotingImagePath } from "./utils.js";
|
|
6
6
|
import { pathToDataUrl } from "../../methods/mulmo_media_source.js";
|
|
@@ -170,6 +170,24 @@ const processSlide = async (params) => {
|
|
|
170
170
|
await renderHTMLToImage(html, imagePath, canvasSize.width, canvasSize.height);
|
|
171
171
|
return imagePath;
|
|
172
172
|
};
|
|
173
|
+
/**
|
|
174
|
+
* A slide beat as markup for the export document, not as a document of its own.
|
|
175
|
+
*
|
|
176
|
+
* `generateSlideHTML` above is right for the PNG path, where the slide IS the page. Here the
|
|
177
|
+
* result is concatenated into one `<body>` alongside every other beat, so a full document per
|
|
178
|
+
* slide meant N Tailwind CDN loads, N `tailwind.config` assignments racing each other, and an
|
|
179
|
+
* `html, body { overflow: hidden }` escaping into the export page — which made a document of
|
|
180
|
+
* stacked slides unscrollable. `beat_html/slide.ts` already draws the browser path from
|
|
181
|
+
* `generateSlideFragment`; this is the same change for the dump path.
|
|
182
|
+
*
|
|
183
|
+
* The fragment's own css is emitted next to it because it is confined to `scopeClass`, which
|
|
184
|
+
* deck makes unique per call. What is SHARED — Tailwind, the chart and mermaid runtimes,
|
|
185
|
+
* `slideUtilityCss` — belongs to the page and is `actions/html.ts`'s job, once.
|
|
186
|
+
*
|
|
187
|
+
* The box is here rather than left to the host: the fragment is `w-full h-full`, so without
|
|
188
|
+
* one it collapses to nothing, and `actions/html.ts` concatenates beats without knowing which
|
|
189
|
+
* of them is a slide.
|
|
190
|
+
*/
|
|
173
191
|
const dumpHtml = async (params) => {
|
|
174
192
|
const { beat } = params;
|
|
175
193
|
if (!beat.image || beat.image.type !== imageType)
|
|
@@ -178,7 +196,8 @@ const dumpHtml = async (params) => {
|
|
|
178
196
|
const slide = resolveSlide(params);
|
|
179
197
|
const reference = beat.image.reference;
|
|
180
198
|
const resolvedBranding = await resolveAndConvertBranding(params);
|
|
181
|
-
|
|
199
|
+
const fragment = generateSlideFragment(theme, slide, { reference, branding: resolvedBranding });
|
|
200
|
+
return `<style>${fragment.css}</style>\n<div style="aspect-ratio: 16 / 9;">${fragment.html}</div>`;
|
|
182
201
|
};
|
|
183
202
|
export const process = processSlide;
|
|
184
203
|
export const path = parrotingImagePath;
|
|
@@ -67,6 +67,13 @@ export interface SwipeElement {
|
|
|
67
67
|
* this a sound narrowing rather than a cast wearing a guard's clothes.
|
|
68
68
|
*/
|
|
69
69
|
export declare const isSwipeElements: (value: unknown) => value is SwipeElement[];
|
|
70
|
+
/**
|
|
71
|
+
* The root the elements are positioned inside. Named so a browser host can give it a height:
|
|
72
|
+
* the root asks for 100% of its parent, and an inline height wins over any rule aimed at the
|
|
73
|
+
* root itself, so the parent is where the box has to come from. The document path has a
|
|
74
|
+
* viewport to fill and needs nothing.
|
|
75
|
+
*/
|
|
76
|
+
export declare const SWIPE_ROOT_ATTRIBUTE = "data-mulmo-swipe-root";
|
|
70
77
|
/** Generate HTML from Swipe elements */
|
|
71
78
|
export declare const swipeElementsToHtml: (elements: SwipeElement[]) => string;
|
|
72
79
|
/** Generate render() script from Swipe element animations */
|
|
@@ -113,10 +113,17 @@ const elementToHtml = (el, index) => {
|
|
|
113
113
|
lines.push("</div>");
|
|
114
114
|
return lines.join("\n");
|
|
115
115
|
};
|
|
116
|
+
/**
|
|
117
|
+
* The root the elements are positioned inside. Named so a browser host can give it a height:
|
|
118
|
+
* the root asks for 100% of its parent, and an inline height wins over any rule aimed at the
|
|
119
|
+
* root itself, so the parent is where the box has to come from. The document path has a
|
|
120
|
+
* viewport to fill and needs nothing.
|
|
121
|
+
*/
|
|
122
|
+
export const SWIPE_ROOT_ATTRIBUTE = "data-mulmo-swipe-root";
|
|
116
123
|
/** Generate HTML from Swipe elements */
|
|
117
124
|
export const swipeElementsToHtml = (elements) => {
|
|
118
125
|
const html = elements.map((el, i) => elementToHtml(el, i)).join("\n");
|
|
119
|
-
return `<div style="position:relative; width:100%; height:100%; overflow:hidden;">\n${html}\n</div>`;
|
|
126
|
+
return `<div ${SWIPE_ROOT_ATTRIBUTE} style="position:relative; width:100%; height:100%; overflow:hidden;">\n${html}\n</div>`;
|
|
120
127
|
};
|
|
121
128
|
const collectAnimations = (elements, entries, indexBase = 0) => {
|
|
122
129
|
elements.forEach((el, i) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mulmocast",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.node.js",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"scripting": "npx tsx ./src/cli/bin.ts tool scripting",
|
|
67
67
|
"prompt": "npx tsx ./src/cli/bin.ts tool prompt",
|
|
68
68
|
"schema": "npx tsx ./src/cli/bin.ts tool schema",
|
|
69
|
+
"schema:write": "npx tsx ./src/cli/bin.ts tool schema > assets/schemas/mulmo_script.json",
|
|
69
70
|
"markdown": "npx tsx ./src/cli/bin.ts markdown",
|
|
70
71
|
"html": "npx tsx ./src/cli/bin.ts html",
|
|
71
72
|
"story_to_script": "npx tsx ./src/cli/bin.ts tool story_to_script",
|
|
@@ -127,6 +128,7 @@
|
|
|
127
128
|
"@receptron/test_utils": "^2.0.3",
|
|
128
129
|
"@types/archiver": "^8.0.0",
|
|
129
130
|
"@types/jsdom": "^28.0.3",
|
|
131
|
+
"@types/node": "^26.0.0",
|
|
130
132
|
"@types/yargs": "^17.0.35",
|
|
131
133
|
"cross-env": "^10.1.0",
|
|
132
134
|
"esbuild": "^0.28.2",
|