@berlysia/vertical-writing-slide-system 0.0.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 ADDED
@@ -0,0 +1,73 @@
1
+ # Slides Application
2
+
3
+ A React-based slides application built with Vite that supports markdown-based presentations with vertical and horizontal writing modes. Features hot module reloading and comprehensive browser compatibility testing.
4
+
5
+ ## Features
6
+
7
+ - Support for both vertical and horizontal writing modes (縦書き・横書き)
8
+ - MDX-based slide creation
9
+ - Print mode support
10
+ - Browser compatibility across Chrome, Firefox, and Safari
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ vertical-slides <command> [options]
16
+ ```
17
+
18
+ ### Commands
19
+
20
+ - `dev` - Start development server
21
+ - `buildAll` - Build all pages
22
+ - `build` - Build current page
23
+
24
+ ### Options
25
+
26
+ - `--dir, -d <path>` - Specify custom slides directory (required unless --debug)
27
+ - `--name, -n <name>` - Specify custom slide name in directory
28
+ - `--debug` - Enable debug mode
29
+
30
+ ### Examples
31
+
32
+ ```bash
33
+ # Start development server for slides in './my-slides' directory
34
+ vertical-slides dev --dir ./my-slides
35
+
36
+ # Build all slides from specified directory
37
+ vertical-slides buildAll --dir ./my-slides
38
+
39
+ # Build specific slides with custom name
40
+ vertical-slides build --dir ./my-slides --name custom-presentation
41
+ ```
42
+
43
+ For development documentation, see [DEVELOPMENT.md](DEVELOPMENT.md).
44
+
45
+ ## Writing Modes
46
+
47
+ The application supports both vertical and horizontal writing modes:
48
+
49
+ - Vertical (日本語縦書き): Traditional Japanese vertical writing mode
50
+ - Horizontal: Standard left-to-right horizontal writing mode
51
+
52
+ Writing modes can be switched dynamically during presentation.
53
+
54
+ ## Browser Compatibility
55
+
56
+ The application is tested and supported across:
57
+
58
+ - Chrome/Chromium
59
+ - Firefox
60
+ - Safari
61
+
62
+ Visual regression tests ensure consistent rendering across browsers, with special attention to writing mode implementations.
63
+
64
+ ## Known Limitations
65
+
66
+ ### Print Mode
67
+
68
+ Print behavior may be unstable across different browsers and scenarios. Users should test print output in their target browser before final use.
69
+
70
+ ### Browser-Specific Issues
71
+
72
+ - Firefox: Container Style Queries are not yet supported, which may affect some layout features
73
+ - https://webstatus.dev/features/container-style-queries
package/cli.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from "path";
4
+ import { execSync } from "child_process";
5
+ import { parseArgs } from "node:util";
6
+
7
+ async function runDev() {
8
+ const { createServer } = await import("vite");
9
+ const server = await createServer({
10
+ configFile: resolve(import.meta.dirname, "vite.config.ts"),
11
+ root: import.meta.dirname,
12
+ mode: "development",
13
+ });
14
+
15
+ await server.listen();
16
+ console.log(`Development server running at ${server.resolvedUrls?.local[0]}`);
17
+ console.log("Press Ctrl+C to exit");
18
+ }
19
+
20
+ async function runBuildAll() {
21
+ const buildPagesPath = resolve(
22
+ import.meta.dirname,
23
+ "scripts",
24
+ "build-pages.ts",
25
+ );
26
+ const command = `node ${buildPagesPath}`;
27
+
28
+ try {
29
+ execSync(command, { stdio: "inherit" });
30
+ } catch (error) {
31
+ console.error("Error during build:", error);
32
+ process.exit(1);
33
+ }
34
+ console.log("Build completed successfully");
35
+ }
36
+
37
+ async function runBuild() {
38
+ try {
39
+ execSync("pnpm run build", { stdio: "inherit" });
40
+ } catch (error) {
41
+ console.error("Error during build:", error);
42
+ process.exit(1);
43
+ }
44
+ console.log("Build completed successfully");
45
+ }
46
+
47
+ const commands = {
48
+ dev: runDev,
49
+ buildAll: runBuildAll,
50
+ build: runBuild,
51
+ };
52
+
53
+ function printUsage() {
54
+ console.error("Usage: vertical-slides <command> [options]");
55
+ console.error("Commands:");
56
+ console.error(" dev - Start development server");
57
+ console.error(" buildAll - Build all pages");
58
+ console.error(" build - Build current page");
59
+ console.error("");
60
+ console.error("Options:");
61
+ console.error(
62
+ " --dir, -d <path> - Specify custom slides directory (required unless --debug)",
63
+ );
64
+ console.error(
65
+ " --name, -n <name> - Specify custom slide name in directory",
66
+ );
67
+ console.error(" --debug - Enable debug mode");
68
+ }
69
+
70
+ async function main() {
71
+ const { values, positionals } = parseArgs({
72
+ args: process.argv.slice(2),
73
+ options: {
74
+ dir: {
75
+ type: "string",
76
+ short: "d",
77
+ },
78
+ name: {
79
+ type: "string",
80
+ short: "n",
81
+ },
82
+ debug: {
83
+ type: "boolean",
84
+ default: false,
85
+ },
86
+ },
87
+ allowPositionals: true,
88
+ });
89
+
90
+ const [command] = positionals;
91
+
92
+ if (!command || !commands[command]) {
93
+ printUsage();
94
+ process.exit(1);
95
+ }
96
+
97
+ if (!values.dir && !values.debug) {
98
+ console.error("Error: --dir option is required (unless --debug is used)");
99
+ printUsage();
100
+ process.exit(1);
101
+ }
102
+
103
+ if (values.dir) {
104
+ process.env.SLIDES_DIR = values.dir;
105
+ }
106
+ if (values.name) {
107
+ process.env.SLIDE_NAME = values.name;
108
+ }
109
+
110
+ try {
111
+ await commands[command]();
112
+ } catch (error) {
113
+ console.error(`Error during ${command}:`, error);
114
+ process.exit(1);
115
+ }
116
+ }
117
+
118
+ main();
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@berlysia/vertical-writing-slide-system",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "bin": {
6
+ ".": "./cli.js",
7
+ "vertical-slides": "./cli.js"
8
+ },
9
+ "files": [
10
+ "cli.js",
11
+ "scripts",
12
+ "src"
13
+ ],
14
+ "dependencies": {
15
+ "@emotion/react": "^11.14.0",
16
+ "@mdx-js/react": "^3.1.0",
17
+ "@mdx-js/rollup": "^3.1.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0",
20
+ "rehype-react": "^8.0.0",
21
+ "rehype-stringify": "^10.0.1",
22
+ "remark-parse": "^11.0.0",
23
+ "remark-rehype": "^11.1.1",
24
+ "unified": "^11.0.5",
25
+ "vfile": "^6.0.3"
26
+ },
27
+ "devDependencies": {
28
+ "@eslint/js": "^9.21.0",
29
+ "@mdx-js/mdx": "^3.1.0",
30
+ "@playwright/experimental-ct-react": "^1.51.0",
31
+ "@playwright/test": "^1.51.0",
32
+ "@types/mdast": "^4.0.4",
33
+ "@types/mdx": "^2.0.13",
34
+ "@types/node": "^22.13.9",
35
+ "@types/prompts": "^2.4.9",
36
+ "@types/react": "^19.0.10",
37
+ "@types/react-dom": "^19.0.4",
38
+ "@vitejs/plugin-react-swc": "^3.5.0",
39
+ "eslint": "^9.21.0",
40
+ "eslint-plugin-react-hooks": "^5.2.0",
41
+ "eslint-plugin-react-refresh": "^0.4.18",
42
+ "globals": "^16.0.0",
43
+ "playwright-core": "^1.51.0",
44
+ "prettier": "^3.5.3",
45
+ "prompts": "^2.4.2",
46
+ "typescript": "~5.8.2",
47
+ "typescript-eslint": "^8.26.0",
48
+ "unist-util-visit": "^5.0.0",
49
+ "vite": "^6.2.1"
50
+ },
51
+ "scripts": {
52
+ "dev": "vite",
53
+ "build": "tsc -b && vite build",
54
+ "lint": "eslint .",
55
+ "preview": "vite preview",
56
+ "build:pages": "node scripts/build-pages.ts",
57
+ "test:vrt": "playwright test",
58
+ "test:vrt:clear": "rm -rf tests/__snapshots__",
59
+ "test:vrt:update": "playwright test --update-snapshots",
60
+ "ai:test:vrt": "AI=1 playwright test",
61
+ "ai:test:vrt:update": "AI=1 playwright test --update-snapshots"
62
+ }
63
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from "child_process";
4
+ import { existsSync } from "fs";
5
+ import { mkdir, readdir, stat, cp, writeFile } from "fs/promises";
6
+ import { join, resolve } from "path";
7
+
8
+ const defaultSlidesDir = resolve(import.meta.dirname, "..", "slides");
9
+ const slidesDir = process.env.SLIDES_DIR;
10
+ const pagesDir = "pages";
11
+
12
+ // Ensure pages directory exists
13
+ await mkdir(pagesDir, { recursive: true });
14
+
15
+ async function buildSlide(slideName: string) {
16
+ console.log(`Building ${slideName}...`);
17
+ process.env.SLIDE_NAME = slideName;
18
+ execSync("pnpm run build", { stdio: "inherit" });
19
+
20
+ const slideOutputDir = join(pagesDir, slideName);
21
+ await mkdir(slideOutputDir, { recursive: true });
22
+ await cp("dist", slideOutputDir, { recursive: true });
23
+ }
24
+
25
+ async function createIndexPage(slideNames: string[]) {
26
+ const slides = slideNames
27
+ .map((name) => ` <li><a href="${name}/">${name}</a></li>`)
28
+ .join("\n");
29
+
30
+ const html = `<!DOCTYPE html>
31
+ <html>
32
+ <head>
33
+ <meta charset="UTF-8">
34
+ <title>Slides Index</title>
35
+ </head>
36
+ <body>
37
+ <h1>Available Slides</h1>
38
+ <ul>
39
+ ${slides}
40
+ </ul>
41
+ </body>
42
+ </html>`;
43
+
44
+ await writeFile(join(pagesDir, "index.html"), html);
45
+ }
46
+
47
+ async function main() {
48
+ const resolvedSlidesDir = slidesDir ?? defaultSlidesDir;
49
+ // Build all slides
50
+ if (!existsSync(resolvedSlidesDir)) {
51
+ throw new Error(`Slides directory not found (tried: ${resolvedSlidesDir})`);
52
+ }
53
+ const slideNames = await readdir(resolvedSlidesDir);
54
+ const slideStats = await Promise.all(
55
+ slideNames.map((item) => stat(join(resolvedSlidesDir, item))),
56
+ );
57
+ const slides = slideNames.filter((_, index) =>
58
+ slideStats[index].isDirectory(),
59
+ );
60
+
61
+ if (slides.length === 0) {
62
+ throw new Error("No slides found");
63
+ }
64
+
65
+ for (const slide of slides) {
66
+ await buildSlide(slide);
67
+ }
68
+ await createIndexPage(slides);
69
+ }
70
+
71
+ await main();
package/src/App.tsx ADDED
@@ -0,0 +1,186 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import slidesContent from "virtual:slides.js";
3
+
4
+ function App() {
5
+ const [writingMode, setWritingMode] = useState("vertical-rl");
6
+ const isVertical = writingMode !== "horizontal-tb";
7
+ const slidesRef = useRef<HTMLDivElement>(null);
8
+
9
+ const [fontSize, setFontSize] = useState(42);
10
+ const [withAbsoluteFontSize, setWithAbsoluteFontSize] = useState(false);
11
+
12
+ // ロード時にハッシュが入ってたらそのページにスクロール
13
+ useEffect(() => {
14
+ const hash = location.hash;
15
+ if (hash) {
16
+ const target = document.querySelector(hash);
17
+ if (target) {
18
+ target.scrollIntoView();
19
+ }
20
+ }
21
+ }, [writingMode]);
22
+
23
+ // IntersectionObserverでスクロール位置に応じてページ番号を変更
24
+ useEffect(() => {
25
+ const observer = new IntersectionObserver(
26
+ (entries) => {
27
+ entries.forEach((entry) => {
28
+ if (entry.isIntersecting) {
29
+ const id = entry.target.id;
30
+ const index = parseInt(id.replace("page-", ""));
31
+ history.replaceState(null, "", `#page-${index}`);
32
+ }
33
+ });
34
+ },
35
+ { threshold: 0.5 },
36
+ );
37
+ if (slidesRef.current) {
38
+ slidesRef.current.querySelectorAll(".slide").forEach((slide) => {
39
+ observer.observe(slide);
40
+ });
41
+ }
42
+ return () => {
43
+ observer.disconnect();
44
+ };
45
+ }, []);
46
+
47
+ function gotoNextSlide(forward = true) {
48
+ const currentHash = location.hash;
49
+ const currentIndex = parseInt(currentHash.replace("#page-", ""));
50
+ const nextIndex = forward ? currentIndex + 1 : currentIndex - 1;
51
+ if (nextIndex < 0 || nextIndex >= slidesContent.length) {
52
+ return;
53
+ }
54
+ location.hash = `#page-${nextIndex}`;
55
+ }
56
+
57
+ // keydownイベントでページ送り
58
+ useEffect(() => {
59
+ const handleKeydown = (event: KeyboardEvent) => {
60
+ if (
61
+ event.key === "ArrowLeft" ||
62
+ event.key === "ArrowDown" ||
63
+ (event.key === " " && !event.shiftKey)
64
+ ) {
65
+ event.preventDefault();
66
+ gotoNextSlide();
67
+ } else if (
68
+ event.key === "ArrowRight" ||
69
+ event.key === "ArrowUp" ||
70
+ (event.key === " " && event.shiftKey)
71
+ ) {
72
+ event.preventDefault();
73
+ gotoNextSlide(false);
74
+ }
75
+ };
76
+ const handleWheel = (event: WheelEvent) => {
77
+ if (event.deltaY > 0) {
78
+ gotoNextSlide();
79
+ } else if (event.deltaY < 0) {
80
+ gotoNextSlide(false);
81
+ }
82
+ };
83
+ const controller = new AbortController();
84
+ window.addEventListener("keydown", handleKeydown, {
85
+ signal: controller.signal,
86
+ });
87
+ window.addEventListener("wheel", handleWheel, {
88
+ signal: controller.signal,
89
+ });
90
+ return () => {
91
+ controller.abort();
92
+ };
93
+ }, [gotoNextSlide]);
94
+
95
+ return (
96
+ <div
97
+ className="slides-container"
98
+ style={{ "--slide-writing-mode": writingMode }}
99
+ >
100
+ <div className="slides" ref={slidesRef}>
101
+ {slidesContent.map((content, index) => {
102
+ if (typeof content === "string") {
103
+ return (
104
+ <div className="slide" id={`page-${index}`} key={index}>
105
+ <div
106
+ className="slide-content"
107
+ style={
108
+ withAbsoluteFontSize ? { fontSize: `${fontSize}px` } : {}
109
+ }
110
+ dangerouslySetInnerHTML={{
111
+ __html: content,
112
+ }}
113
+ />
114
+ </div>
115
+ );
116
+ } else {
117
+ const SlideComponent = content;
118
+ return (
119
+ <div className="slide" id={`page-${index}`} key={index}>
120
+ <div
121
+ className="slide-content"
122
+ style={
123
+ withAbsoluteFontSize ? { fontSize: `${fontSize}px` } : {}
124
+ }
125
+ >
126
+ <SlideComponent />
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+ })}
132
+ </div>
133
+ <div className="controls">
134
+ <div>
135
+ <button type="button" onClick={() => gotoNextSlide()}>
136
+
137
+ </button>
138
+ <button
139
+ type="button"
140
+ onClick={() =>
141
+ setWritingMode(isVertical ? "horizontal-tb" : "vertical-rl")
142
+ }
143
+ >
144
+ {isVertical ? "横書きにする" : "縦書きにする"}
145
+ </button>
146
+ <button type="button" onClick={() => gotoNextSlide(false)}>
147
+
148
+ </button>
149
+ </div>
150
+ <div>
151
+ <label>
152
+ フォントサイズを指定する
153
+ <input
154
+ type="checkbox"
155
+ checked={withAbsoluteFontSize}
156
+ onChange={(e) => setWithAbsoluteFontSize(e.target.checked)}
157
+ />
158
+ </label>
159
+ <label>
160
+ <input
161
+ type="number"
162
+ min="10"
163
+ step="1"
164
+ value={fontSize}
165
+ onChange={(e) => {
166
+ const t = Number(e.target.value);
167
+ setFontSize(Number.isNaN(t) || t < 4 ? 4 : t);
168
+ }}
169
+ onKeyDown={(e) => {
170
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
171
+ e.stopPropagation();
172
+ }
173
+ }}
174
+ style={{
175
+ inlineSize: `${fontSize.toString(10).length / 2 + 2}em`,
176
+ }}
177
+ />
178
+ px
179
+ </label>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export default App;
@@ -0,0 +1,7 @@
1
+ import "react";
2
+
3
+ declare module "react" {
4
+ interface CSSProperties {
5
+ [key: `--${string}`]: string | number;
6
+ }
7
+ }
package/src/index.css ADDED
@@ -0,0 +1,101 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ html {
6
+ font-family:
7
+ "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP",
8
+ sans-serif;
9
+ font-weight: 600;
10
+ font-style: normal;
11
+
12
+ /* Prevent text size adjustments */
13
+ -webkit-text-size-adjust: 100%;
14
+ text-size-adjust: 100%;
15
+
16
+ text-rendering: geometricPrecision;
17
+ }
18
+
19
+ code {
20
+ font-family: "Noto Sans Mono", monospace;
21
+ font-optical-sizing: auto;
22
+ font-variation-settings: "wdth" 100;
23
+ }
24
+
25
+ @property --slide-writing-mode {
26
+ syntax: "horizontal-tb | vertical-rl | vertical-lr | sideways-rl | sideways-lr";
27
+ inherits: true;
28
+ initial-value: horizontal-tb;
29
+ }
30
+
31
+ .slide-content {
32
+ width: 100%;
33
+ height: 100%;
34
+
35
+ position: relative;
36
+
37
+ .wrapper {
38
+ width: 100%;
39
+ height: 100%;
40
+ padding: 1.2em 1.2em;
41
+
42
+ position: relative;
43
+
44
+ &.center {
45
+ display: block flex;
46
+ flex-direction: column;
47
+ justify-content: center;
48
+ align-items: center;
49
+ }
50
+ }
51
+ .header-and-content {
52
+ display: block flex;
53
+ flex-direction: column;
54
+ max-block-size: 100%;
55
+ max-inline-size: 100%;
56
+ overflow: hidden;
57
+ }
58
+
59
+ h1 {
60
+ font-size: 1.4em;
61
+ font-weight: bold;
62
+ margin: 0;
63
+ margin-block-end: 0.2em;
64
+ }
65
+ ul,
66
+ ol {
67
+ list-style-position: outside;
68
+ margin: 0;
69
+ margin-block-end: 1em;
70
+ }
71
+ ul ul,
72
+ ol ol {
73
+ margin-block-end: 1em;
74
+ }
75
+ ul ul ul,
76
+ ol ol ol {
77
+ margin-block-end: 0;
78
+ }
79
+ p {
80
+ margin: 0;
81
+ margin-block-end: 0.5em;
82
+ }
83
+
84
+ .wm-toggle {
85
+ @container style(--slide-writing-mode: vertical-rl) {
86
+ /* !importantがないと上書きできない。詳細度? */
87
+ writing-mode: horizontal-tb !important;
88
+ }
89
+ @container style(--slide-writing-mode: horizontal-tb) or style(--slide-writing-mode: unset) {
90
+ writing-mode: vertical-rl;
91
+ }
92
+ }
93
+
94
+ .wm-horizontal {
95
+ writing-mode: horizontal-tb;
96
+ }
97
+
98
+ .wm-vertical {
99
+ writing-mode: vertical-rl;
100
+ }
101
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,9 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App.tsx";
4
+
5
+ createRoot(document.getElementById("root")!).render(
6
+ <StrictMode>
7
+ <App />
8
+ </StrictMode>,
9
+ );
package/src/print.css ADDED
@@ -0,0 +1,103 @@
1
+ @property --print-slide-width {
2
+ syntax: "<length>";
3
+ inherits: true;
4
+ initial-value: 0px;
5
+ }
6
+
7
+ @property --print-slide-height {
8
+ syntax: "<length>";
9
+ inherits: true;
10
+ initial-value: 0px;
11
+ }
12
+
13
+ :root {
14
+ --print-page-width: 1920px;
15
+ --print-page-height: 1080px;
16
+ --print-slide-width: calc(var(--print-page-width) - 0px);
17
+ --print-slide-height: calc(var(--print-page-height) - 0px);
18
+ --print-slide-font-size: calc(var(--print-slide-height) / 20);
19
+ }
20
+
21
+ @page {
22
+ /* Base page size for all browsers */
23
+ size: var(--print-page-width) var(--print-page-height);
24
+ margin: 0;
25
+ }
26
+
27
+ html,
28
+ body,
29
+ #root {
30
+ width: 100% !important;
31
+ height: 100% !important;
32
+ margin: 0 !important;
33
+ padding: 0 !important;
34
+ overflow: visible !important;
35
+ }
36
+
37
+ body {
38
+ /* Ensure print colors are accurate */
39
+ -webkit-print-color-adjust: exact;
40
+ print-color-adjust: exact;
41
+ /* Prevent any scaling */
42
+ transform: none !important;
43
+
44
+ font-size: var(--print-slide-font-size);
45
+ }
46
+
47
+ /* Hide controls in print */
48
+ .controls {
49
+ display: none !important;
50
+ }
51
+
52
+ /* Slides wrapper adjustments */
53
+ .slides {
54
+ position: relative;
55
+ }
56
+
57
+ .slide {
58
+ /* Size and position */
59
+ width: 100%;
60
+ height: 100%;
61
+
62
+ /* Writing mode handling */
63
+ writing-mode: var(--slide-writing-mode);
64
+
65
+ /* Force page breaks */
66
+ page-break-after: always;
67
+ break-after: page;
68
+ page-break-inside: avoid;
69
+ break-inside: avoid;
70
+
71
+ /* Print layout */
72
+ position: relative;
73
+ overflow: hidden;
74
+ /* Ensure text remains crisp in print */
75
+ -webkit-font-smoothing: antialiased;
76
+ -moz-osx-font-smoothing: grayscale;
77
+ /* Maintain font settings */
78
+ }
79
+
80
+ /* Writing mode toggle specific styles */
81
+ .wm-toggle {
82
+ writing-mode: inherit !important;
83
+ }
84
+
85
+ .debug {
86
+ background-color: orange;
87
+
88
+ #root {
89
+ background-color: yellow;
90
+ }
91
+
92
+ .slides-container {
93
+ background-color: green;
94
+ }
95
+
96
+ .slide {
97
+ background-color: red;
98
+ }
99
+
100
+ .slide-content {
101
+ background-color: pink;
102
+ }
103
+ }
@@ -0,0 +1,56 @@
1
+ import type { Plugin } from "unified";
2
+ import { visit } from "unist-util-visit";
3
+ import type { Root } from "mdast";
4
+
5
+ interface RemarkSlideImagesOptions {
6
+ base: string;
7
+ }
8
+
9
+ const remarkSlideImages: Plugin<[RemarkSlideImagesOptions], Root> = (
10
+ options,
11
+ ) => {
12
+ const { base } = options;
13
+
14
+ return (tree) => {
15
+ visit(tree, (node: any) => {
16
+ // Handle Markdown image syntax
17
+ if (node.type === "image" && typeof node.url === "string") {
18
+ if (node.url.startsWith("@slide/")) {
19
+ node.url = `${base}slide-assets/images/${node.url.slice(7)}`;
20
+ }
21
+ }
22
+
23
+ // Handle MDX JSX img elements
24
+ if (node.type === "mdxJsxFlowElement" && node.name === "img") {
25
+ const src = node.attributes?.find(
26
+ (attr: any) => attr.type === "mdxJsxAttribute" && attr.name === "src",
27
+ );
28
+ if (
29
+ src &&
30
+ typeof src.value === "string" &&
31
+ src.value.startsWith("@slide/")
32
+ ) {
33
+ src.value = `${base}slide-assets/images/${src.value.slice(7)}`;
34
+ }
35
+ }
36
+
37
+ // Handle HTML img tags in Markdown
38
+ if (node.type === "html") {
39
+ const value = node.value as string;
40
+ if (value.startsWith("<img")) {
41
+ node.value = value.replace(
42
+ /<img\s+([^>]*src="(@slide\/[^"]+)"[^>]*)>/g,
43
+ (_, attributes, src) => {
44
+ return `<img ${attributes.replace(
45
+ src,
46
+ `${base}slide-assets/images/${src.slice(7)}`,
47
+ )}>`;
48
+ },
49
+ );
50
+ }
51
+ }
52
+ });
53
+ };
54
+ };
55
+
56
+ export default remarkSlideImages;
package/src/screen.css ADDED
@@ -0,0 +1,101 @@
1
+ html,
2
+ body,
3
+ #root {
4
+ width: 100%;
5
+ height: 100%;
6
+ margin: 0;
7
+ padding: 0;
8
+ overflow: hidden;
9
+ }
10
+
11
+ .slides-container {
12
+ container-name: slide-container;
13
+ container-type: size;
14
+
15
+ width: 100dvw;
16
+ height: calc(max(90dvh, 100dvw * 9 / 16) + 0px);
17
+
18
+ position: relative;
19
+
20
+ overflow: hidden;
21
+
22
+ display: flex;
23
+ flex-direction: column;
24
+ justify-content: center;
25
+ align-items: center;
26
+
27
+ border: 1px solid #ccc;
28
+ background-color: #eee;
29
+ }
30
+
31
+ .slides {
32
+ /* コンテナーの中を最大に占める16/9の寸法 */
33
+ width: 100cqw;
34
+ height: 100cqh;
35
+ /* Fix container query precision issues */
36
+ max-width: calc(100cqh * 16 / 9 - 0.01px);
37
+ max-height: calc(100cqw * 9 / 16 - 0.01px);
38
+
39
+ scroll-snap-type: both mandatory;
40
+ writing-mode: var(--slide-writing-mode);
41
+
42
+ overflow: hidden;
43
+
44
+ @container style(--slide-writing-mode: vertical-rl) or style(--slide-writing-mode: vertical-lr) or style(--slide-writing-mode: sideways-rl) or style(--slide-writing-mode: sideways-lr) {
45
+ overflow-x: scroll;
46
+ }
47
+ @container style(--slide-writing-mode: horizontal-tb) or style(--slide-writing-mode: unset) {
48
+ overflow-y: scroll;
49
+ }
50
+ }
51
+
52
+ /* 各スライドのスタイル */
53
+ .slide {
54
+ width: 100cqw;
55
+ height: 100cqh;
56
+ /* Fix container query precision issues */
57
+ max-width: calc(100cqh * 16 / 9 - 0.01px);
58
+ max-height: calc(100cqw * 9 / 16 - 0.01px);
59
+
60
+ container-name: slide-container;
61
+ container-type: size;
62
+
63
+ background-color: white;
64
+
65
+ scroll-snap-align: center;
66
+ /* Safari and Firefox need this for consistent scroll snapping */
67
+ scroll-snap-stop: always;
68
+
69
+ overflow: hidden;
70
+
71
+ /* Support for older versions of Firefox */
72
+ scrollbar-width: none;
73
+ -ms-overflow-style: none;
74
+ /* Support for older versions of Safari */
75
+ -webkit-overflow-scrolling: touch;
76
+ }
77
+
78
+ /* Hide scrollbars consistently across browsers */
79
+ .slide::-webkit-scrollbar {
80
+ display: none;
81
+ }
82
+
83
+ .controls {
84
+ position: fixed;
85
+ left: 40px;
86
+ bottom: 40px;
87
+ background-color: rgba(255, 255, 255, 0.9);
88
+ opacity: 0.3;
89
+ padding: 1rem;
90
+ border-radius: 8px;
91
+ transition: opacity 0.2s ease-out;
92
+ }
93
+
94
+ .controls:hover,
95
+ .controls:focus-within {
96
+ opacity: 1;
97
+ }
98
+
99
+ .slide-content {
100
+ font-size: 5cqh;
101
+ }
@@ -0,0 +1,13 @@
1
+ import { PropsWithChildren } from "react";
2
+
3
+ export function Layout({ children }: PropsWithChildren) {
4
+ return <div className="wrapper">{children}</div>;
5
+ }
6
+
7
+ export function Center({ children }: PropsWithChildren) {
8
+ return <div className="wrapper center">{children}</div>;
9
+ }
10
+
11
+ export function HeaderAndContent({ children }: PropsWithChildren) {
12
+ return <div className="wrapper header-and-content">{children}</div>;
13
+ }
@@ -0,0 +1,12 @@
1
+ import { type PropsWithChildren, type JSX } from "react";
2
+
3
+ type SlideProps = PropsWithChildren<{
4
+ children: (() => JSX.Element) | JSX.Element;
5
+ }>;
6
+
7
+ export function Slide({ children }: SlideProps) {
8
+ if (typeof children === "function") {
9
+ return children();
10
+ }
11
+ return children;
12
+ }
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "virtual:slides.js" {
4
+ type SlideContent = string | (() => JSX.Element) | React.ComponentType;
5
+ const content: SlideContent[];
6
+ export default content;
7
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,353 @@
1
+ import type { Plugin, ViteDevServer, ResolvedConfig } from "vite";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { mkdirSync, copyFileSync, readdirSync } from "node:fs";
5
+ import prompts from "prompts";
6
+ import { compile } from "@mdx-js/mdx";
7
+ import { unified } from "unified";
8
+ import remarkParse from "remark-parse";
9
+ import remarkRehype from "remark-rehype";
10
+ import rehypeStringify from "rehype-stringify";
11
+ import remarkSlideImages from "./remark-slide-images";
12
+
13
+ async function processMarkdown(markdown: string, base: string) {
14
+ return await unified()
15
+ .use(remarkParse)
16
+ .use(remarkSlideImages, { base })
17
+ .use(remarkRehype, { allowDangerousHtml: true })
18
+ .use(rehypeStringify, { allowDangerousHtml: true })
19
+ .process(markdown);
20
+ }
21
+
22
+ export interface SlidesPluginOptions {
23
+ /** Directory containing the slides (absolute path) */
24
+ slidesDir?: string;
25
+ /** Name of the slides collection */
26
+ collection?: string;
27
+ }
28
+
29
+ /**
30
+ * Logger for slides functionality
31
+ */
32
+ const logger = {
33
+ info: (message: string) => console.log(`[Slides] ${message}`),
34
+ error: (message: string, error?: Error) => {
35
+ console.error(`[Slides] ${message}`);
36
+ if (error) {
37
+ console.error(error);
38
+ }
39
+ },
40
+ warn: (message: string) => console.warn(`[Slides] ${message}`),
41
+ };
42
+
43
+ /**
44
+ * Validates slides directory
45
+ * @throws {Error} If directory is invalid or inaccessible
46
+ */
47
+ function validateSlidesDir(dir: string): void {
48
+ if (!fs.existsSync(dir)) {
49
+ throw new Error(`External slides directory not found: ${dir}`);
50
+ }
51
+ try {
52
+ fs.accessSync(dir, fs.constants.R_OK);
53
+ } catch (err) {
54
+ throw new Error(`No read permission for external slides directory: ${dir}`);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Sets up file watcher for slides directory
60
+ */
61
+ function watchSlidesDir(dir: string, server: ViteDevServer): () => void {
62
+ logger.info(`Watching slides directory: ${dir}`);
63
+
64
+ const watcher = fs.watch(dir, { recursive: true }, (_eventType, filename) => {
65
+ if (filename) {
66
+ server.ws.send({
67
+ type: "full-reload",
68
+ path: "*",
69
+ });
70
+ logger.info(`File changed: ${filename}`);
71
+ }
72
+ });
73
+
74
+ watcher.on("error", (error) => {
75
+ logger.error(`Watch error:`, error);
76
+ });
77
+
78
+ return () => {
79
+ watcher.close();
80
+ logger.info("Stopped watching slides directory");
81
+ };
82
+ }
83
+
84
+ const defaultOptions: Required<SlidesPluginOptions> = {
85
+ slidesDir: path.resolve(process.cwd(), "slides"),
86
+ collection: "",
87
+ };
88
+
89
+ async function selectSlideCollection(slidesDir: string): Promise<string> {
90
+ const slidesPath = path.resolve(slidesDir);
91
+
92
+ if (!fs.existsSync(slidesPath)) {
93
+ throw new Error(`Slides directory not found: ${slidesPath}`);
94
+ }
95
+
96
+ const collections = readdirSync(slidesPath, { withFileTypes: true })
97
+ .filter((dirent) => dirent.isDirectory())
98
+ .map((dirent) => dirent.name);
99
+
100
+ if (collections.length === 0) {
101
+ throw new Error(`No slide collections found in ${slidesPath}`);
102
+ }
103
+
104
+ const response = await prompts({
105
+ type: "select",
106
+ name: "collection",
107
+ message: "Select a slide collection:",
108
+ choices: collections.map((name) => ({ title: name, value: name })),
109
+ });
110
+
111
+ if (!response.collection) {
112
+ throw new Error("No slide collection selected");
113
+ }
114
+
115
+ return response.collection;
116
+ }
117
+
118
+ const virtualFileId = "virtual:slides.js";
119
+
120
+ function virtualFilePageId(index: number) {
121
+ return `virtual:slides-page-${index}.js`;
122
+ }
123
+ const virtualFilePageIdPattern = /^virtual:slides-page-(\d+)\.js$/;
124
+ const nullPrefixedVirtualFilePageIdPattern =
125
+ /^\0virtual:slides-page-(\d+)\.js$/;
126
+
127
+ export default async function slidesPlugin(
128
+ options: SlidesPluginOptions = {},
129
+ ): Promise<Plugin> {
130
+ const mergedOptions = { ...defaultOptions, ...options };
131
+
132
+ // Validate slides directory
133
+ try {
134
+ validateSlidesDir(mergedOptions.slidesDir);
135
+ logger.info(`Using slides directory: ${mergedOptions.slidesDir}`);
136
+ } catch (error) {
137
+ if (error instanceof Error) {
138
+ logger.error("Failed to validate slides directory", error);
139
+ }
140
+ throw error;
141
+ }
142
+
143
+ const config = {
144
+ ...mergedOptions,
145
+ collection:
146
+ mergedOptions.collection ||
147
+ (await selectSlideCollection(mergedOptions.slidesDir)),
148
+ };
149
+ let base: string;
150
+ let compiledSlides: string[] = [];
151
+ return {
152
+ name: "vite-plugin-slides",
153
+ configResolved(config: ResolvedConfig) {
154
+ base = config.base;
155
+ },
156
+ enforce: "pre",
157
+ resolveId(id: string) {
158
+ if (id === virtualFileId) {
159
+ return "\0" + virtualFileId;
160
+ }
161
+ if (virtualFilePageIdPattern.test(id)) {
162
+ return "\0" + id;
163
+ }
164
+ },
165
+ transform(code, id) {
166
+ if (
167
+ id === "\0" + virtualFileId ||
168
+ nullPrefixedVirtualFilePageIdPattern.test(id)
169
+ ) {
170
+ return {
171
+ code: `import React from 'react';\n${code}`,
172
+ map: null,
173
+ };
174
+ }
175
+ },
176
+ async load(id: string) {
177
+ if (id === "\0" + virtualFileId) {
178
+ // Look for slides in the configured directory
179
+ const mdxPath = path.resolve(
180
+ config.slidesDir,
181
+ config.collection,
182
+ "index.mdx",
183
+ );
184
+ const mdPath = path.resolve(
185
+ config.slidesDir,
186
+ config.collection,
187
+ "index.md",
188
+ );
189
+
190
+ let filePath: string | undefined;
191
+ let isMdx = false;
192
+
193
+ if (fs.existsSync(mdxPath)) {
194
+ filePath = mdxPath;
195
+ isMdx = true;
196
+ logger.info("Using MDX slides");
197
+ } else if (fs.existsSync(mdPath)) {
198
+ filePath = mdPath;
199
+ logger.info("Using MD slides");
200
+ } else {
201
+ logger.warn("No slide files found");
202
+ return "export default []";
203
+ }
204
+
205
+ const content = fs.readFileSync(filePath, "utf-8");
206
+
207
+ if (!isMdx) {
208
+ const slides = content.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
209
+ const processedSlides = await Promise.all(
210
+ slides.map((slide) => processMarkdown(slide, base)),
211
+ );
212
+ return `export default ${JSON.stringify(processedSlides.map((p) => p.value))}`;
213
+ }
214
+
215
+ const slides = content.split(/^\s*(?:---|\*\*\*|___)\s*$/m);
216
+
217
+ const processedSlides = await Promise.all(
218
+ slides.map(async (slideContent) => {
219
+ const result = await compile(slideContent, {
220
+ outputFormat: "program",
221
+ development: false,
222
+ remarkPlugins: [[remarkSlideImages, { base }]],
223
+ });
224
+ return result.value as string;
225
+ }),
226
+ );
227
+
228
+ compiledSlides = processedSlides;
229
+
230
+ const numberOfSlides = slides.length;
231
+ function formatSlideIndex(index: number) {
232
+ return index
233
+ .toString()
234
+ .padStart(numberOfSlides.toString(10).length, "0");
235
+ }
236
+
237
+ // read src/slide-components directory
238
+ const slideComponentsDir = path.resolve(
239
+ import.meta.dirname,
240
+ `slide-components`,
241
+ );
242
+ const slideComponentsFilenames = fs.existsSync(slideComponentsDir)
243
+ ? readdirSync(slideComponentsDir)
244
+ : [];
245
+ function filenameToComponentName(filename: string) {
246
+ return filename.replace(/\.[jt]sx?$/, "");
247
+ }
248
+
249
+ // Return as a module
250
+ return `
251
+ ${slideComponentsFilenames.map((filename) => `import * as ${filenameToComponentName(filename)} from '@components/${filename}';`).join("\n")}
252
+
253
+ const SlideComponents = {${slideComponentsFilenames.map((filename) => `...${filenameToComponentName(filename)}`).join(", ")}};
254
+
255
+ ${compiledSlides.map((_, index) => `import Slide${formatSlideIndex(index)} from '${virtualFilePageId(index)}';`).join("\n")}
256
+
257
+ // provide slide components to each slide
258
+ // Wrap SlideN components to provide SlideComponents
259
+ ${compiledSlides
260
+ .map(
261
+ (_, index) => `
262
+ const Slide${formatSlideIndex(index)}WithComponents = (props) => {
263
+ return React.createElement(Slide${formatSlideIndex(index)}, {
264
+ ...props,
265
+ components: SlideComponents
266
+ });
267
+ };
268
+ `,
269
+ )
270
+ .join("\n")}
271
+ export default [${compiledSlides.map((_, i) => `Slide${formatSlideIndex(i)}WithComponents`).join(", ")}];
272
+ `;
273
+ }
274
+
275
+ if (nullPrefixedVirtualFilePageIdPattern.test(id)) {
276
+ const match = id.match(nullPrefixedVirtualFilePageIdPattern);
277
+ if (match) {
278
+ const index = parseInt(match[1], 10);
279
+ return compiledSlides[index];
280
+ }
281
+ }
282
+ },
283
+ async buildStart() {
284
+ const targetImagesDir = path.resolve(
285
+ process.cwd(),
286
+ "public/slide-assets/images",
287
+ );
288
+ const sourceImagesDir = path.resolve(
289
+ config.slidesDir,
290
+ config.collection,
291
+ "images",
292
+ );
293
+
294
+ // Copy images from slides directory
295
+ if (fs.existsSync(sourceImagesDir)) {
296
+ try {
297
+ // Create target directory if it doesn't exist
298
+ mkdirSync(targetImagesDir, { recursive: true });
299
+
300
+ // Copy all files from source to target
301
+ const imageFiles = readdirSync(sourceImagesDir);
302
+ for (const file of imageFiles) {
303
+ const sourcePath = path.join(sourceImagesDir, file);
304
+ const targetPath = path.join(targetImagesDir, file);
305
+ copyFileSync(sourcePath, targetPath);
306
+ }
307
+ logger.info("Copied slide images successfully");
308
+ } catch (error) {
309
+ if (error instanceof Error) {
310
+ logger.error("Failed to copy slide images", error);
311
+ }
312
+ throw error;
313
+ }
314
+ }
315
+ },
316
+
317
+ configureServer(server: ViteDevServer) {
318
+ // Watch slides directory
319
+ const watcher = watchSlidesDir(config.slidesDir, server);
320
+
321
+ const reloadModule = () => {
322
+ const mod = server.moduleGraph.getModuleById("\0" + virtualFileId);
323
+ const pageMods = compiledSlides.map((_, i) =>
324
+ server.moduleGraph.getModuleById(virtualFilePageId(i)),
325
+ );
326
+ if (mod) {
327
+ server.moduleGraph.invalidateModule(mod);
328
+ }
329
+ for (const pageMod of pageMods) {
330
+ if (pageMod) {
331
+ server.moduleGraph.invalidateModule(pageMod);
332
+ }
333
+ }
334
+ server.ws.send({
335
+ type: "full-reload",
336
+ path: "*",
337
+ });
338
+ };
339
+
340
+ server.watcher.on("change", (path: string) => {
341
+ if (path.includes(config.slidesDir) && /\.(?:md|mdx)$/.test(path)) {
342
+ logger.info(`Slide file changed: ${path}`);
343
+ reloadModule();
344
+ }
345
+ });
346
+
347
+ // Cleanup on server shutdown
348
+ return () => {
349
+ watcher();
350
+ };
351
+ },
352
+ };
353
+ }