@berlysia/vertical-writing-slide-system 0.0.21 → 0.0.23

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.21",
3
+ "version": "0.0.23",
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;
package/src/index.css CHANGED
@@ -40,7 +40,7 @@ code {
40
40
  .wrapper {
41
41
  width: 100%;
42
42
  height: 100%;
43
- padding: 0.833%;
43
+ padding: 2rem;
44
44
 
45
45
  position: relative;
46
46
 
@@ -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,39 @@ 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
322
  return `export default ${JSON.stringify(processedSlides.map((p) => p.value))}`;
264
323
  }
265
324
 
266
325
  const slides = content.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
267
326
 
268
- // MDXにもCSS抽出を適用(MDXの場合はJSXのstyleタグを抽出)
327
+ // MDXにもCSS・スクリプト抽出を適用
269
328
  const extractedStyles: string[] = [];
329
+ const extractedScripts: ParsedScript = { external: [], inline: [] };
270
330
  const processedSlides = await Promise.all(
271
331
  slides.map(async (slideContent) => {
272
332
  // MDX内のstyleタグを手動で抽出(簡易実装)
@@ -278,8 +338,15 @@ export default async function slidesPlugin(
278
338
  }
279
339
  }
280
340
 
281
- // styleタグを削除したコンテンツでMDXコンパイル
282
- const cleanedContent = slideContent.replace(styleRegex, "");
341
+ // MDX内のscriptタグを抽出
342
+ const parsedScripts = ScriptManager.parseScripts(slideContent);
343
+ extractedScripts.external.push(...parsedScripts.external);
344
+ extractedScripts.inline.push(...parsedScripts.inline);
345
+
346
+ // style・scriptタグを削除したコンテンツでMDXコンパイル
347
+ let cleanedContent = slideContent.replace(styleRegex, "");
348
+ cleanedContent =
349
+ ScriptManager.removeScriptsFromContent(cleanedContent);
283
350
 
284
351
  const result = await compile(cleanedContent, {
285
352
  outputFormat: "program",
@@ -292,8 +359,10 @@ export default async function slidesPlugin(
292
359
  }),
293
360
  );
294
361
 
295
- // 抽出されたスタイルを追加
362
+ // 抽出されたスタイル・スクリプトを追加
296
363
  slideStyles = [...slideStyles, ...extractedStyles];
364
+ slideScripts.external.push(...extractedScripts.external);
365
+ slideScripts.inline.push(...extractedScripts.inline);
297
366
 
298
367
  compiledSlides = processedSlides;
299
368
 
@@ -322,6 +391,9 @@ export default async function slidesPlugin(
322
391
  ? JSON.stringify(slideStyles.join("\n\n"))
323
392
  : "null";
324
393
 
394
+ // スライド固有のスクリプトを文字列として生成
395
+ const slideScriptsString = JSON.stringify(slideScripts);
396
+
325
397
  // Return as a module with CSS injection
326
398
  return `
327
399
  ${slideComponentsFilenames.map((filename) => `import * as ${filenameToComponentName(filename)} from '@components/${filename}';`).join("\n")}
@@ -344,6 +416,9 @@ export default async function slidesPlugin(
344
416
  }
345
417
  }
346
418
 
419
+ // スライド固有のスクリプトを外部から利用可能にする
420
+ export const slideScripts = ${slideScriptsString};
421
+
347
422
  // provide slide components to each slide
348
423
  // Wrap SlideN components to provide SlideComponents
349
424
  ${compiledSlides