@berlysia/vertical-writing-slide-system 0.0.31 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@berlysia/vertical-writing-slide-system",
3
- "version": "0.0.31",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vertical-slides": "./cli.js"
@@ -15,12 +15,27 @@
15
15
  "src",
16
16
  "index.html"
17
17
  ],
18
+ "scripts": {
19
+ "dev": "vite",
20
+ "build": "tsc -b && vite build",
21
+ "build:cli": "tsc --project tsconfig.cli.json",
22
+ "preversion": "pnpm build:cli",
23
+ "lint": "eslint .",
24
+ "preview": "vite preview",
25
+ "build:pages": "node scripts/build-pages.ts",
26
+ "test:vrt": "playwright test",
27
+ "test:vrt:clear": "rm -rf tests/__snapshots__",
28
+ "test:vrt:update": "playwright test --update-snapshots",
29
+ "ai:test:vrt": "AI=1 playwright test",
30
+ "ai:test:vrt:update": "AI=1 playwright test --update-snapshots"
31
+ },
18
32
  "dependencies": {
19
33
  "@emotion/react": "^11.14.0",
20
34
  "@mdx-js/mdx": "^3.1.0",
21
35
  "@mdx-js/react": "^3.1.0",
22
36
  "@mdx-js/rollup": "^3.1.0",
23
37
  "@vitejs/plugin-react-swc": "^3.5.0",
38
+ "gray-matter": "^4.0.3",
24
39
  "prompts": "^2.4.2",
25
40
  "react": "^19.0.0",
26
41
  "react-dom": "^19.0.0",
@@ -54,19 +69,5 @@
54
69
  "typescript": "~5.8.2",
55
70
  "typescript-eslint": "^8.26.0",
56
71
  "unist-util-visit": "^5.0.0"
57
- },
58
- "scripts": {
59
- "dev": "vite",
60
- "build": "tsc -b && vite build",
61
- "build:cli": "tsc --project tsconfig.cli.json",
62
- "preversion": "pnpm build:cli",
63
- "lint": "eslint .",
64
- "preview": "vite preview",
65
- "build:pages": "node scripts/build-pages.ts",
66
- "test:vrt": "playwright test",
67
- "test:vrt:clear": "rm -rf tests/__snapshots__",
68
- "test:vrt:update": "playwright test --update-snapshots",
69
- "ai:test:vrt": "AI=1 playwright test",
70
- "ai:test:vrt:update": "AI=1 playwright test --update-snapshots"
71
72
  }
72
- }
73
+ }
@@ -0,0 +1,14 @@
1
+ declare module "gray-matter" {
2
+ interface GrayMatterFile {
3
+ data: any;
4
+ content: string;
5
+ excerpt?: string;
6
+ orig: string | Buffer;
7
+ language: string;
8
+ matter: string;
9
+ stringify(lang?: string): string;
10
+ }
11
+
12
+ function matter(input: string | Buffer): GrayMatterFile;
13
+ export = matter;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ /**
2
+ * スライドのメタデータ型定義
3
+ */
4
+ export interface SlideMetadata {
5
+ /** スライドのタイトル */
6
+ title?: string;
7
+ /** スライドの説明 */
8
+ description?: string;
9
+ /** 作成者 */
10
+ author?: string;
11
+ /** 作成日時 */
12
+ date?: string;
13
+ /** OGP画像のパス(ogp.pngが存在する場合に自動設定) */
14
+ ogImage?: string;
15
+ /** その他のカスタムフィールド */
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ /**
20
+ * frontmatterをパースした結果
21
+ */
22
+ export interface ParsedSlideFile {
23
+ /** パースされたメタデータ */
24
+ metadata: SlideMetadata;
25
+ /** メタデータを除いたコンテンツ */
26
+ content: string;
27
+ /** 元のファイル内容 */
28
+ originalContent: string;
29
+ }
@@ -1,7 +1,13 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
3
  declare module "virtual:slides.js" {
4
+ import type { ParsedScript } from "./script-manager";
5
+ import type { SlideMetadata } from "./types/slide-metadata";
6
+
4
7
  type SlideContent = string | (() => JSX.Element) | React.ComponentType;
5
8
  const content: SlideContent[];
6
9
  export default content;
10
+
11
+ export const slideScripts: ParsedScript;
12
+ export const slideMetadata: SlideMetadata;
7
13
  }
@@ -1,4 +1,9 @@
1
- import type { Plugin, ViteDevServer, ResolvedConfig } from "vite";
1
+ import type {
2
+ Plugin,
3
+ ViteDevServer,
4
+ ResolvedConfig,
5
+ HtmlTagDescriptor,
6
+ } from "vite";
2
7
  import * as fs from "node:fs";
3
8
  import * as path from "node:path";
4
9
  import { mkdirSync, readdirSync } from "node:fs";
@@ -14,6 +19,8 @@ import { ScriptManager, type ParsedScript } from "./script-manager";
14
19
  import { visit } from "unist-util-visit";
15
20
  import type { Node } from "unist";
16
21
  import type { Element, Text, ElementContent } from "hast";
22
+ import matter from "gray-matter";
23
+ import type { SlideMetadata, ParsedSlideFile } from "./types/slide-metadata";
17
24
 
18
25
  /**
19
26
  * CSS抽出用のrehypeプラグイン
@@ -87,6 +94,22 @@ function loadAdjacentCSS(slidesDir: string, collection: string): string[] {
87
94
  return [];
88
95
  }
89
96
 
97
+ /**
98
+ * OGP画像ファイル(ogp.png)が存在するか確認
99
+ * 存在する場合はファイル名を返す
100
+ */
101
+ function checkOgpImage(slidesDir: string, collection: string): string | null {
102
+ const collectionDir = path.resolve(slidesDir, collection);
103
+ const ogpPath = path.resolve(collectionDir, "ogp.png");
104
+
105
+ if (fs.existsSync(ogpPath)) {
106
+ logger.info("Found OGP image: ogp.png");
107
+ return "ogp.png";
108
+ }
109
+
110
+ return null;
111
+ }
112
+
90
113
  /**
91
114
  * 隣接スクリプト設定ファイルを検索して読み込み
92
115
  */
@@ -124,6 +147,37 @@ function loadAdjacentScripts(
124
147
  return result;
125
148
  }
126
149
 
150
+ /**
151
+ * ファイル内容からfrontmatterをパースして、メタデータとコンテンツに分離
152
+ */
153
+ function parseSlideFile(
154
+ content: string,
155
+ collectionName: string,
156
+ ): ParsedSlideFile {
157
+ try {
158
+ const parsed = matter(content);
159
+ const metadata: SlideMetadata = {
160
+ // デフォルトタイトルとしてコレクション名を使用
161
+ title: collectionName,
162
+ ...parsed.data,
163
+ };
164
+
165
+ return {
166
+ metadata,
167
+ content: parsed.content,
168
+ originalContent: content,
169
+ };
170
+ } catch (error) {
171
+ logger.warn(`Failed to parse frontmatter: ${error}`);
172
+ // パースに失敗した場合はfrontmatterなしとして扱う
173
+ return {
174
+ metadata: { title: collectionName },
175
+ content,
176
+ originalContent: content,
177
+ };
178
+ }
179
+ }
180
+
127
181
  export interface SlidesPluginOptions {
128
182
  /** Directory containing the slides (absolute path) */
129
183
  slidesDir?: string;
@@ -231,6 +285,7 @@ export default async function slidesPlugin(
231
285
  let resolvedConfig: ResolvedConfig;
232
286
  let slideStyles: string[] = [];
233
287
  let slideScripts: ParsedScript = { external: [], inline: [] };
288
+ let slideMetadata: SlideMetadata = { title: config.collection };
234
289
  return {
235
290
  name: "vite-plugin-slides",
236
291
  configResolved(config: ResolvedConfig) {
@@ -288,6 +343,19 @@ export default async function slidesPlugin(
288
343
 
289
344
  const content = fs.readFileSync(filePath, "utf-8");
290
345
 
346
+ // frontmatterをパースしてメタデータとコンテンツを分離
347
+ const parsedFile = parseSlideFile(content, config.collection);
348
+ slideMetadata = parsedFile.metadata;
349
+ const slideContent = parsedFile.content;
350
+
351
+ // OGP画像を検出してメタデータに設定
352
+ const ogpImageFile = checkOgpImage(config.slidesDir, config.collection);
353
+ if (ogpImageFile) {
354
+ slideMetadata.ogImage = `${base}slide-assets/${ogpImageFile}`;
355
+ }
356
+
357
+ logger.info(`Slide metadata: ${JSON.stringify(slideMetadata)}`);
358
+
291
359
  // 隣接CSSファイルを読み込み
292
360
  const adjacentStyles = loadAdjacentCSS(
293
361
  config.slidesDir,
@@ -306,7 +374,7 @@ export default async function slidesPlugin(
306
374
  };
307
375
 
308
376
  if (!isMdx) {
309
- const slides = content.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
377
+ const slides = slideContent.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
310
378
  const extractedStyles: string[] = [];
311
379
  const extractedScripts: ParsedScript = { external: [], inline: [] };
312
380
  const processedSlides = await Promise.all(
@@ -349,29 +417,29 @@ export default async function slidesPlugin(
349
417
  `;
350
418
  }
351
419
 
352
- const slides = content.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
420
+ const slides = slideContent.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
353
421
 
354
422
  // MDXにもCSS・スクリプト抽出を適用
355
423
  const extractedStyles: string[] = [];
356
424
  const extractedScripts: ParsedScript = { external: [], inline: [] };
357
425
  const processedSlides = await Promise.all(
358
- slides.map(async (slideContent) => {
426
+ slides.map(async (slide) => {
359
427
  // MDX内のstyleタグを手動で抽出(簡易実装)
360
428
  const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
361
429
  let match;
362
- while ((match = styleRegex.exec(slideContent)) !== null) {
430
+ while ((match = styleRegex.exec(slide)) !== null) {
363
431
  if (match[1].trim()) {
364
432
  extractedStyles.push(match[1].trim());
365
433
  }
366
434
  }
367
435
 
368
436
  // MDX内のscriptタグを抽出
369
- const parsedScripts = ScriptManager.parseScripts(slideContent);
437
+ const parsedScripts = ScriptManager.parseScripts(slide);
370
438
  extractedScripts.external.push(...parsedScripts.external);
371
439
  extractedScripts.inline.push(...parsedScripts.inline);
372
440
 
373
441
  // style・scriptタグを削除したコンテンツでMDXコンパイル
374
- let cleanedContent = slideContent.replace(styleRegex, "");
442
+ let cleanedContent = slide.replace(styleRegex, "");
375
443
  cleanedContent =
376
444
  ScriptManager.removeScriptsFromContent(cleanedContent);
377
445
 
@@ -446,6 +514,9 @@ export default async function slidesPlugin(
446
514
  // スライド固有のスクリプトを外部から利用可能にする
447
515
  export const slideScripts = ${slideScriptsString};
448
516
 
517
+ // スライドのメタデータを外部から利用可能にする
518
+ export const slideMetadata = ${JSON.stringify(slideMetadata)};
519
+
449
520
  // provide slide components to each slide
450
521
  // Wrap SlideN components to provide SlideComponents
451
522
  ${compiledSlides
@@ -526,6 +597,29 @@ export default async function slidesPlugin(
526
597
  } else {
527
598
  logger.info(`No images directory found at: ${sourceImagesDir}`);
528
599
  }
600
+
601
+ // OGP画像をコピー
602
+ const targetAssetsDir = isExternalCLI
603
+ ? path.resolve(process.cwd(), "public/slide-assets")
604
+ : path.resolve(resolvedConfig.root, "public/slide-assets");
605
+ const sourceOgpPath = path.resolve(
606
+ config.slidesDir,
607
+ config.collection,
608
+ "ogp.png",
609
+ );
610
+
611
+ if (fs.existsSync(sourceOgpPath)) {
612
+ try {
613
+ mkdirSync(targetAssetsDir, { recursive: true });
614
+ const targetOgpPath = path.join(targetAssetsDir, "ogp.png");
615
+ await copyFile(sourceOgpPath, targetOgpPath);
616
+ logger.info("Copied OGP image: ogp.png");
617
+ } catch (error) {
618
+ if (error instanceof Error) {
619
+ logger.error("Failed to copy OGP image", error);
620
+ }
621
+ }
622
+ }
529
623
  }
530
624
  },
531
625
 
@@ -533,12 +627,15 @@ export default async function slidesPlugin(
533
627
  // Generate HTML file if none exists in consumer project
534
628
  const consumerIndexHtml = path.resolve(resolvedConfig.root, "index.html");
535
629
 
536
- if (!fs.existsSync(consumerIndexHtml)) {
630
+ // CLIモードでは常にメタデータ付きHTMLを生成
631
+ const isExternalCLI = process.cwd() !== resolvedConfig.root;
632
+
633
+ if (!fs.existsSync(consumerIndexHtml) || isExternalCLI) {
537
634
  // Find the main JS and CSS files in the bundle
538
635
  const mainJsFile = Object.keys(bundle).find(
539
636
  (fileName) =>
540
637
  fileName.startsWith("assets/") &&
541
- fileName.includes("main-") &&
638
+ (fileName.includes("index-") || fileName.includes("main-")) &&
542
639
  fileName.endsWith(".js"),
543
640
  );
544
641
  const mainCssFile = Object.keys(bundle).find(
@@ -548,6 +645,7 @@ export default async function slidesPlugin(
548
645
 
549
646
  if (!mainJsFile) {
550
647
  logger.error("Could not find main JS file in bundle");
648
+ logger.error("Available bundle files: " + Object.keys(bundle));
551
649
  return;
552
650
  }
553
651
 
@@ -555,12 +653,31 @@ export default async function slidesPlugin(
555
653
  ? `<link rel="stylesheet" href="./${mainCssFile}">`
556
654
  : "<!-- CSS is included in the JS bundle -->";
557
655
 
656
+ const pageTitle = slideMetadata?.title || "Vertical Writing Slides";
657
+
658
+ // OGPメタタグを生成
659
+ const ogpTags: string[] = [];
660
+ if (slideMetadata?.title) {
661
+ ogpTags.push(`<meta property="og:title" content="${slideMetadata.title}" />`);
662
+ }
663
+ if (slideMetadata?.description) {
664
+ ogpTags.push(`<meta property="og:description" content="${slideMetadata.description}" />`);
665
+ }
666
+ if (slideMetadata?.ogImage) {
667
+ ogpTags.push(`<meta property="og:image" content="${slideMetadata.ogImage}" />`);
668
+ ogpTags.push(`<meta name="twitter:card" content="summary_large_image" />`);
669
+ ogpTags.push(`<meta name="twitter:image" content="${slideMetadata.ogImage}" />`);
670
+ }
671
+
558
672
  const virtualIndexHtml = `<!doctype html>
559
673
  <html lang="ja">
560
674
  <head>
561
675
  <meta charset="UTF-8" />
562
676
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
563
- <title>Vertical Writing Slides</title>
677
+ <title>${pageTitle}</title>
678
+ ${slideMetadata?.description ? `<meta name="description" content="${slideMetadata.description}" />` : ""}
679
+ ${slideMetadata?.author ? `<meta name="author" content="${slideMetadata.author}" />` : ""}
680
+ ${ogpTags.join("\n ")}
564
681
  <link rel="preconnect" href="https://fonts.googleapis.com">
565
682
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
566
683
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP&family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
@@ -633,6 +750,30 @@ export default async function slidesPlugin(
633
750
  } else {
634
751
  logger.warn(`No images directory found at: ${sourceImagesDir}`);
635
752
  }
753
+
754
+ // OGP画像をバンドルに追加
755
+ const sourceOgpPath = path.resolve(
756
+ config.slidesDir,
757
+ config.collection,
758
+ "ogp.png",
759
+ );
760
+
761
+ if (fs.existsSync(sourceOgpPath)) {
762
+ try {
763
+ const ogpContent = fs.readFileSync(sourceOgpPath);
764
+ this.emitFile({
765
+ type: "asset",
766
+ fileName: "slide-assets/ogp.png",
767
+ source: ogpContent,
768
+ });
769
+ logger.info("Added OGP image to bundle: ogp.png");
770
+ } catch (error) {
771
+ logger.error(
772
+ "Failed to add OGP image to bundle",
773
+ error instanceof Error ? error : new Error(String(error)),
774
+ );
775
+ }
776
+ }
636
777
  }
637
778
  },
638
779
 
@@ -713,5 +854,89 @@ export default async function slidesPlugin(
713
854
  // No additional cleanup needed since we're using Vite's built-in watcher
714
855
  };
715
856
  },
857
+
858
+ transformIndexHtml(html: string) {
859
+ // 既存の <title> タグを削除
860
+ const htmlWithoutTitle = html.replace(/<title>.*?<\/title>/i, "");
861
+
862
+ // メタデータタグを構築
863
+ const title = slideMetadata?.title || config.collection;
864
+ const tags: HtmlTagDescriptor[] = [
865
+ {
866
+ tag: "title",
867
+ children: title,
868
+ },
869
+ ];
870
+
871
+ if (slideMetadata?.description) {
872
+ tags.push({
873
+ tag: "meta",
874
+ attrs: {
875
+ name: "description",
876
+ content: slideMetadata.description,
877
+ },
878
+ });
879
+ }
880
+
881
+ if (slideMetadata?.author) {
882
+ tags.push({
883
+ tag: "meta",
884
+ attrs: {
885
+ name: "author",
886
+ content: slideMetadata.author,
887
+ },
888
+ });
889
+ }
890
+
891
+ // OGPメタタグを追加
892
+ if (slideMetadata?.title) {
893
+ tags.push({
894
+ tag: "meta",
895
+ attrs: {
896
+ property: "og:title",
897
+ content: slideMetadata.title,
898
+ },
899
+ });
900
+ }
901
+
902
+ if (slideMetadata?.description) {
903
+ tags.push({
904
+ tag: "meta",
905
+ attrs: {
906
+ property: "og:description",
907
+ content: slideMetadata.description,
908
+ },
909
+ });
910
+ }
911
+
912
+ if (slideMetadata?.ogImage) {
913
+ tags.push({
914
+ tag: "meta",
915
+ attrs: {
916
+ property: "og:image",
917
+ content: slideMetadata.ogImage,
918
+ },
919
+ });
920
+ tags.push({
921
+ tag: "meta",
922
+ attrs: {
923
+ name: "twitter:card",
924
+ content: "summary_large_image",
925
+ },
926
+ });
927
+ tags.push({
928
+ tag: "meta",
929
+ attrs: {
930
+ name: "twitter:image",
931
+ content: slideMetadata.ogImage,
932
+ },
933
+ });
934
+ }
935
+
936
+ return {
937
+ html: htmlWithoutTitle,
938
+ tags,
939
+ };
940
+ },
716
941
  };
717
942
  }
package/tsconfig.app.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "composite": true,
3
4
  "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
5
  "target": "ES2020",
5
6
  "useDefineForClassFields": true,
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "composite": true,
3
4
  "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
5
  "target": "ES2022",
5
- "lib": ["ES2023"],
6
+ "lib": ["ES2023", "DOM"],
6
7
  "module": "ESNext",
7
8
  "skipLibCheck": true,
8
9
 
@@ -20,5 +21,11 @@
20
21
  "noFallthroughCasesInSwitch": true,
21
22
  "noUncheckedSideEffectImports": true
22
23
  },
23
- "include": ["vite.config.ts"]
24
+ "include": [
25
+ "vite.config.ts",
26
+ "src/vite-plugin-slides.ts",
27
+ "src/remark-slide-images.ts",
28
+ "src/script-manager.ts",
29
+ "src/types/**/*.ts"
30
+ ]
24
31
  }