@berlysia/vertical-writing-slide-system 0.0.22 → 0.0.24

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 CHANGED
@@ -6,6 +6,7 @@ A React-based slides application built with Vite that supports markdown-based pr
6
6
 
7
7
  - Support for both vertical and horizontal writing modes (縦書き・横書き)
8
8
  - MDX-based slide creation
9
+ - External script embedding with security validation
9
10
  - Print mode support
10
11
  - Browser compatibility across Chrome, Firefox, and Safari
11
12
 
@@ -60,6 +61,54 @@ Each presentation should be in its own directory containing:
60
61
 
61
62
  - `index.mdx`: The main presentation file with your MDX content. \*.md is also fine
62
63
  - `images/`: (Optional) Directory containing images used in the presentation
64
+ - `scripts.json`: (Optional) Configuration file for external scripts
65
+ - `style.css`: (Optional) Custom CSS styles for the presentation
66
+
67
+ ## Script Embedding
68
+
69
+ You can embed external scripts in your slides for enhanced functionality:
70
+
71
+ ### Method 1: Direct Script Tags in MDX
72
+
73
+ ```mdx
74
+ <script
75
+ src="https://cdn.jsdelivr.net/npm/baseline-status@1/baseline-status.min.js"
76
+ type="module"
77
+ ></script>
78
+
79
+ <script>console.log('Inline script executed');</script>
80
+
81
+ <Center>
82
+ <h1>External Script Example</h1>
83
+ <baseline-status feature="css-grid"></baseline-status>
84
+ </Center>
85
+ ```
86
+
87
+ ### Method 2: Configuration File
88
+
89
+ Create a `scripts.json` file in your slide directory:
90
+
91
+ ```json
92
+ {
93
+ "external": [
94
+ {
95
+ "src": "https://cdn.jsdelivr.net/npm/baseline-status@1/baseline-status.min.js",
96
+ "type": "module"
97
+ }
98
+ ],
99
+ "inline": [
100
+ {
101
+ "content": "console.log('Script from configuration');"
102
+ }
103
+ ]
104
+ }
105
+ ```
106
+
107
+ ### Security Features
108
+
109
+ - Only trusted CDNs are allowed (jsdelivr, unpkg, cdnjs, etc.)
110
+ - Scripts are loaded only once per session
111
+ - Basic validation of script attributes and content
63
112
 
64
113
  For development documentation, see [DEVELOPMENT.md](DEVELOPMENT.md).
65
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@berlysia/vertical-writing-slide-system",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vertical-slides": "./cli.js"
package/src/App.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useState, useRef, useEffect, useCallback } from "react";
2
- import slidesContent from "virtual:slides.js";
2
+ import slidesContent, { slideScripts } from "virtual:slides.js";
3
+ import { globalScriptManager } from "./script-manager";
3
4
 
4
5
  function App() {
5
6
  const [writingMode, setWritingMode] = useState(() => {
@@ -34,6 +35,21 @@ function App() {
34
35
  );
35
36
  }, [withAbsoluteFontSize]);
36
37
 
38
+ // スクリプトの読み込み
39
+ useEffect(() => {
40
+ if (
41
+ slideScripts &&
42
+ (slideScripts.external.length > 0 || slideScripts.inline.length > 0)
43
+ ) {
44
+ console.log("[App] Loading slide scripts:", slideScripts);
45
+ globalScriptManager.loadScripts(slideScripts).catch(console.error);
46
+ }
47
+
48
+ return () => {
49
+ // クリーンアップは慎重に行う(全てのスクリプトを削除すると他の機能に影響する場合がある)
50
+ };
51
+ }, []);
52
+
37
53
  // ロード時にハッシュが入ってたらそのページにスクロール
38
54
  useEffect(() => {
39
55
  const hash = location.hash;
@@ -0,0 +1,210 @@
1
+ export interface ScriptInfo {
2
+ src?: string;
3
+ type?: string;
4
+ async?: boolean;
5
+ defer?: boolean;
6
+ crossorigin?: string;
7
+ integrity?: string;
8
+ content?: string;
9
+ id?: string;
10
+ }
11
+
12
+ export interface ParsedScript {
13
+ external: ScriptInfo[];
14
+ inline: ScriptInfo[];
15
+ }
16
+
17
+ export class ScriptManager {
18
+ private loadedScripts = new Set<string>();
19
+ private scriptElements = new Map<string, HTMLScriptElement>();
20
+
21
+ static parseScripts(content: string): ParsedScript {
22
+ const external: ScriptInfo[] = [];
23
+ const inline: ScriptInfo[] = [];
24
+
25
+ const scriptRegex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
26
+ let match;
27
+
28
+ while ((match = scriptRegex.exec(content)) !== null) {
29
+ const attributes = match[1];
30
+ const scriptContent = match[2].trim();
31
+
32
+ const scriptInfo: ScriptInfo = {};
33
+
34
+ const srcMatch = attributes.match(/src\s*=\s*["']([^"']+)["']/);
35
+ if (srcMatch) {
36
+ scriptInfo.src = srcMatch[1];
37
+ }
38
+
39
+ const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
40
+ if (typeMatch) {
41
+ scriptInfo.type = typeMatch[1];
42
+ }
43
+
44
+ const asyncMatch = attributes.match(/\basync\b/);
45
+ if (asyncMatch) {
46
+ scriptInfo.async = true;
47
+ }
48
+
49
+ const deferMatch = attributes.match(/\bdefer\b/);
50
+ if (deferMatch) {
51
+ scriptInfo.defer = true;
52
+ }
53
+
54
+ const crossoriginMatch = attributes.match(
55
+ /crossorigin\s*=\s*["']([^"']+)["']/,
56
+ );
57
+ if (crossoriginMatch) {
58
+ scriptInfo.crossorigin = crossoriginMatch[1];
59
+ }
60
+
61
+ const integrityMatch = attributes.match(
62
+ /integrity\s*=\s*["']([^"']+)["']/,
63
+ );
64
+ if (integrityMatch) {
65
+ scriptInfo.integrity = integrityMatch[1];
66
+ }
67
+
68
+ const idMatch = attributes.match(/id\s*=\s*["']([^"']+)["']/);
69
+ if (idMatch) {
70
+ scriptInfo.id = idMatch[1];
71
+ }
72
+
73
+ if (scriptInfo.src) {
74
+ external.push(scriptInfo);
75
+ } else if (scriptContent) {
76
+ scriptInfo.content = scriptContent;
77
+ inline.push(scriptInfo);
78
+ }
79
+ }
80
+
81
+ return { external, inline };
82
+ }
83
+
84
+ static removeScriptsFromContent(content: string): string {
85
+ return content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
86
+ }
87
+
88
+ static isValidExternalScript(src: string): boolean {
89
+ try {
90
+ const url = new URL(src);
91
+ const allowedProtocols = ["https:", "http:"];
92
+ const trustedDomains = [
93
+ "cdn.jsdelivr.net",
94
+ "unpkg.com",
95
+ "cdnjs.cloudflare.com",
96
+ "code.jquery.com",
97
+ "stackpath.bootstrapcdn.com",
98
+ ];
99
+
100
+ if (!allowedProtocols.includes(url.protocol)) {
101
+ return false;
102
+ }
103
+
104
+ return trustedDomains.some(
105
+ (domain) =>
106
+ url.hostname === domain || url.hostname.endsWith(`.${domain}`),
107
+ );
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ async loadScript(scriptInfo: ScriptInfo): Promise<void> {
114
+ if (scriptInfo.src) {
115
+ return this.loadExternalScript(scriptInfo);
116
+ } else if (scriptInfo.content) {
117
+ return this.loadInlineScript(scriptInfo);
118
+ }
119
+ }
120
+
121
+ private async loadExternalScript(scriptInfo: ScriptInfo): Promise<void> {
122
+ const { src } = scriptInfo;
123
+ if (!src) return;
124
+
125
+ if (!ScriptManager.isValidExternalScript(src)) {
126
+ console.warn(
127
+ `[ScriptManager] External script blocked for security: ${src}`,
128
+ );
129
+ return;
130
+ }
131
+
132
+ const scriptId = scriptInfo.id || `script-${src}`;
133
+
134
+ if (this.loadedScripts.has(scriptId)) {
135
+ return;
136
+ }
137
+
138
+ return new Promise((resolve, reject) => {
139
+ const script = document.createElement("script");
140
+
141
+ script.src = src;
142
+ if (scriptInfo.type) script.type = scriptInfo.type;
143
+ if (scriptInfo.async) script.async = true;
144
+ if (scriptInfo.defer) script.defer = true;
145
+ if (scriptInfo.crossorigin) script.crossOrigin = scriptInfo.crossorigin;
146
+ if (scriptInfo.integrity) script.integrity = scriptInfo.integrity;
147
+ if (scriptInfo.id) script.id = scriptInfo.id;
148
+
149
+ script.onload = () => {
150
+ this.loadedScripts.add(scriptId);
151
+ this.scriptElements.set(scriptId, script);
152
+ console.log(`[ScriptManager] Loaded external script: ${src}`);
153
+ resolve();
154
+ };
155
+
156
+ script.onerror = () => {
157
+ console.error(`[ScriptManager] Failed to load script: ${src}`);
158
+ reject(new Error(`Failed to load script: ${src}`));
159
+ };
160
+
161
+ document.head.appendChild(script);
162
+ });
163
+ }
164
+
165
+ private async loadInlineScript(scriptInfo: ScriptInfo): Promise<void> {
166
+ const { content, id } = scriptInfo;
167
+ if (!content) return;
168
+
169
+ const scriptId = id || `inline-script-${Date.now()}`;
170
+
171
+ if (this.loadedScripts.has(scriptId)) {
172
+ return;
173
+ }
174
+
175
+ const script = document.createElement("script");
176
+ if (scriptInfo.type) script.type = scriptInfo.type;
177
+ if (scriptInfo.id) script.id = scriptInfo.id;
178
+ script.textContent = content;
179
+
180
+ document.head.appendChild(script);
181
+ this.loadedScripts.add(scriptId);
182
+ this.scriptElements.set(scriptId, script);
183
+
184
+ console.log(`[ScriptManager] Loaded inline script: ${scriptId}`);
185
+ }
186
+
187
+ cleanup(): void {
188
+ for (const [scriptId, element] of this.scriptElements) {
189
+ if (element.parentNode) {
190
+ element.parentNode.removeChild(element);
191
+ }
192
+ this.loadedScripts.delete(scriptId);
193
+ }
194
+ this.scriptElements.clear();
195
+ }
196
+
197
+ async loadScripts(scripts: ParsedScript): Promise<void> {
198
+ const allScripts = [...scripts.external, ...scripts.inline];
199
+
200
+ for (const script of allScripts) {
201
+ try {
202
+ await this.loadScript(script);
203
+ } catch (error) {
204
+ console.error("[ScriptManager] Failed to load script:", error);
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ export const globalScriptManager = new ScriptManager();
@@ -9,6 +9,7 @@ import remarkParse from "remark-parse";
9
9
  import remarkRehype from "remark-rehype";
10
10
  import rehypeStringify from "rehype-stringify";
11
11
  import remarkSlideImages from "./remark-slide-images";
12
+ import { ScriptManager, type ParsedScript } from "./script-manager";
12
13
  import { visit } from "unist-util-visit";
13
14
  import type { Node } from "unist";
14
15
  import type { Element, Text, ElementContent } from "hast";
@@ -46,14 +47,21 @@ async function processMarkdown(
46
47
  markdown: string,
47
48
  base: string,
48
49
  extractedStyles: string[],
50
+ extractedScripts: ParsedScript,
49
51
  ) {
52
+ const parsedScripts = ScriptManager.parseScripts(markdown);
53
+ extractedScripts.external.push(...parsedScripts.external);
54
+ extractedScripts.inline.push(...parsedScripts.inline);
55
+
56
+ const cleanedMarkdown = ScriptManager.removeScriptsFromContent(markdown);
57
+
50
58
  return await unified()
51
59
  .use(remarkParse)
52
60
  .use(remarkSlideImages, { base })
53
61
  .use(remarkRehype, { allowDangerousHtml: true })
54
62
  .use(rehypeExtractStyles(extractedStyles))
55
63
  .use(rehypeStringify, { allowDangerousHtml: true })
56
- .process(markdown);
64
+ .process(cleanedMarkdown);
57
65
  }
58
66
 
59
67
  /**
@@ -78,6 +86,43 @@ function loadAdjacentCSS(slidesDir: string, collection: string): string[] {
78
86
  return [];
79
87
  }
80
88
 
89
+ /**
90
+ * 隣接スクリプト設定ファイルを検索して読み込み
91
+ */
92
+ function loadAdjacentScripts(
93
+ slidesDir: string,
94
+ collection: string,
95
+ ): ParsedScript {
96
+ const collectionDir = path.resolve(slidesDir, collection);
97
+ const scriptsPath = path.resolve(collectionDir, "scripts.json");
98
+
99
+ const result: ParsedScript = { external: [], inline: [] };
100
+
101
+ if (fs.existsSync(scriptsPath)) {
102
+ try {
103
+ const scriptsConfig = JSON.parse(fs.readFileSync(scriptsPath, "utf-8"));
104
+
105
+ if (scriptsConfig.external && Array.isArray(scriptsConfig.external)) {
106
+ result.external.push(...scriptsConfig.external);
107
+ logger.info(
108
+ `Loaded ${scriptsConfig.external.length} external scripts from scripts.json`,
109
+ );
110
+ }
111
+
112
+ if (scriptsConfig.inline && Array.isArray(scriptsConfig.inline)) {
113
+ result.inline.push(...scriptsConfig.inline);
114
+ logger.info(
115
+ `Loaded ${scriptsConfig.inline.length} inline scripts from scripts.json`,
116
+ );
117
+ }
118
+ } catch (error) {
119
+ logger.warn("Failed to parse scripts.json:", error);
120
+ }
121
+ }
122
+
123
+ return result;
124
+ }
125
+
81
126
  export interface SlidesPluginOptions {
82
127
  /** Directory containing the slides (absolute path) */
83
128
  slidesDir?: string;
@@ -184,6 +229,7 @@ export default async function slidesPlugin(
184
229
  let compiledSlides: string[] = [];
185
230
  let resolvedConfig: ResolvedConfig;
186
231
  let slideStyles: string[] = [];
232
+ let slideScripts: ParsedScript = { external: [], inline: [] };
187
233
  return {
188
234
  name: "vite-plugin-slides",
189
235
  configResolved(config: ResolvedConfig) {
@@ -248,25 +294,45 @@ export default async function slidesPlugin(
248
294
  );
249
295
  slideStyles = [...adjacentStyles];
250
296
 
297
+ // 隣接スクリプト設定ファイルを読み込み
298
+ const adjacentScripts = loadAdjacentScripts(
299
+ config.slidesDir,
300
+ config.collection,
301
+ );
302
+ slideScripts = {
303
+ external: [...adjacentScripts.external],
304
+ inline: [...adjacentScripts.inline],
305
+ };
306
+
251
307
  if (!isMdx) {
252
308
  const slides = content.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
253
309
  const extractedStyles: string[] = [];
310
+ const extractedScripts: ParsedScript = { external: [], inline: [] };
254
311
  const processedSlides = await Promise.all(
255
312
  slides.map((slide) =>
256
- processMarkdown(slide, base, extractedStyles),
313
+ processMarkdown(slide, base, extractedStyles, extractedScripts),
257
314
  ),
258
315
  );
259
316
 
260
- // 抽出されたスタイルを追加
317
+ // 抽出されたスタイル・スクリプトを追加
261
318
  slideStyles = [...slideStyles, ...extractedStyles];
319
+ slideScripts.external.push(...extractedScripts.external);
320
+ slideScripts.inline.push(...extractedScripts.inline);
262
321
 
263
- return `export default ${JSON.stringify(processedSlides.map((p) => p.value))}`;
322
+ // スライド固有のスクリプトを文字列として生成
323
+ const slideScriptsString = JSON.stringify(slideScripts);
324
+
325
+ return `
326
+ export default ${JSON.stringify(processedSlides.map((p) => p.value))};
327
+ export const slideScripts = ${slideScriptsString};
328
+ `;
264
329
  }
265
330
 
266
331
  const slides = content.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
267
332
 
268
- // MDXにもCSS抽出を適用(MDXの場合はJSXのstyleタグを抽出)
333
+ // MDXにもCSS・スクリプト抽出を適用
269
334
  const extractedStyles: string[] = [];
335
+ const extractedScripts: ParsedScript = { external: [], inline: [] };
270
336
  const processedSlides = await Promise.all(
271
337
  slides.map(async (slideContent) => {
272
338
  // MDX内のstyleタグを手動で抽出(簡易実装)
@@ -278,8 +344,15 @@ export default async function slidesPlugin(
278
344
  }
279
345
  }
280
346
 
281
- // styleタグを削除したコンテンツでMDXコンパイル
282
- const cleanedContent = slideContent.replace(styleRegex, "");
347
+ // MDX内のscriptタグを抽出
348
+ const parsedScripts = ScriptManager.parseScripts(slideContent);
349
+ extractedScripts.external.push(...parsedScripts.external);
350
+ extractedScripts.inline.push(...parsedScripts.inline);
351
+
352
+ // style・scriptタグを削除したコンテンツでMDXコンパイル
353
+ let cleanedContent = slideContent.replace(styleRegex, "");
354
+ cleanedContent =
355
+ ScriptManager.removeScriptsFromContent(cleanedContent);
283
356
 
284
357
  const result = await compile(cleanedContent, {
285
358
  outputFormat: "program",
@@ -292,8 +365,10 @@ export default async function slidesPlugin(
292
365
  }),
293
366
  );
294
367
 
295
- // 抽出されたスタイルを追加
368
+ // 抽出されたスタイル・スクリプトを追加
296
369
  slideStyles = [...slideStyles, ...extractedStyles];
370
+ slideScripts.external.push(...extractedScripts.external);
371
+ slideScripts.inline.push(...extractedScripts.inline);
297
372
 
298
373
  compiledSlides = processedSlides;
299
374
 
@@ -322,6 +397,9 @@ export default async function slidesPlugin(
322
397
  ? JSON.stringify(slideStyles.join("\n\n"))
323
398
  : "null";
324
399
 
400
+ // スライド固有のスクリプトを文字列として生成
401
+ const slideScriptsString = JSON.stringify(slideScripts);
402
+
325
403
  // Return as a module with CSS injection
326
404
  return `
327
405
  ${slideComponentsFilenames.map((filename) => `import * as ${filenameToComponentName(filename)} from '@components/${filename}';`).join("\n")}
@@ -344,6 +422,9 @@ export default async function slidesPlugin(
344
422
  }
345
423
  }
346
424
 
425
+ // スライド固有のスクリプトを外部から利用可能にする
426
+ export const slideScripts = ${slideScriptsString};
427
+
347
428
  // provide slide components to each slide
348
429
  // Wrap SlideN components to provide SlideComponents
349
430
  ${compiledSlides