rlz-web-slides 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.
@@ -0,0 +1,10 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ export interface PresentationProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
3
+ slides: readonly ReactNode[];
4
+ mode?: 'embedded' | 'fullscreen';
5
+ initialSlide?: number;
6
+ onSlideChange?: (index: number) => void;
7
+ controls?: boolean;
8
+ }
9
+ /** A scoped player. Keyboard navigation belongs to the focused player only. */
10
+ export declare function Presentation({ slides, mode, initialSlide, onSlideChange, controls, ...props }: PresentationProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare function SlideCountProvider({ slideCount, children }: {
3
+ slideCount: number;
4
+ children: ReactNode;
5
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
6
+ export declare function CurrentSlideProvider({ slideNumber, children }: {
7
+ slideNumber: number;
8
+ children: ReactNode;
9
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
10
+ export declare function useSlideCount(): number;
11
+ export declare function useCurrentSlideNumber(): number;
@@ -0,0 +1,7 @@
1
+ export { Slide, type SlideProps } from './components/Slide';
2
+ export { Panel, type PanelProps } from './components/Panel';
3
+ export { SlideNumber, type SlideNumberProps } from './components/SlideNumber';
4
+ export { DecoratedSlide, type DecoratedSlideProps } from './slides/DecoratedSlide';
5
+ export { Presentation, type PresentationProps } from './player/Presentation';
6
+ export { SlidesThemeProvider, resolveSlidesTheme, useSlidesTheme, type AuroraBackgroundOptions, type BackgroundColor, type BackgroundVariants, type GridBackgroundOptions, type MeshBackgroundOptions, type SpotlightBackgroundOptions, type SlidesTheme, type SlidesThemeInput, type TextureBackgroundOptions } from './theme';
7
+ export { css } from '@emotion/react';
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type SlideProps } from '../components/Slide';
3
+ import type { Interpolation, Theme } from '@emotion/react';
4
+ export interface DecoratedSlideProps extends SlideProps {
5
+ headerLeft?: ReactNode;
6
+ headerRight?: ReactNode;
7
+ footerLeft?: ReactNode;
8
+ footerRight?: ReactNode;
9
+ css?: Interpolation<Theme>;
10
+ }
11
+ /** Optional content regions shared by the themed slide templates. */
12
+ export declare function DecoratedSlide({ css, headerLeft, headerRight, footerLeft, footerRight, children, drawing, ...props }: DecoratedSlideProps): import("@emotion/react/jsx-runtime").JSX.Element;
package/lib/theme.d.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { CSSObject } from '@emotion/react';
2
+ import { type ReactNode } from 'react';
3
+ export type ColorName = 'accent-1' | 'accent-2' | 'accent-3' | 'light' | 'dark' | 'neutral';
4
+ export type BackgroundColor = ColorName | (string & {});
5
+ export type Background = (color?: BackgroundColor) => CSSObject;
6
+ export interface TextureBackgroundOptions {
7
+ base?: BackgroundColor;
8
+ dot?: BackgroundColor;
9
+ size?: number;
10
+ }
11
+ export interface AuroraBackgroundOptions {
12
+ base?: BackgroundColor;
13
+ lowerLeft?: BackgroundColor;
14
+ upperRight?: BackgroundColor;
15
+ center?: BackgroundColor;
16
+ }
17
+ export interface MeshBackgroundOptions {
18
+ base?: BackgroundColor;
19
+ colors?: [BackgroundColor, BackgroundColor, BackgroundColor];
20
+ }
21
+ export interface GridBackgroundOptions {
22
+ base?: BackgroundColor;
23
+ line?: BackgroundColor;
24
+ size?: number;
25
+ }
26
+ export interface SpotlightBackgroundOptions {
27
+ base?: BackgroundColor;
28
+ light?: BackgroundColor;
29
+ position?: string;
30
+ }
31
+ export interface BackgroundVariants {
32
+ solid: Background;
33
+ gradient: Background;
34
+ texture: (options?: TextureBackgroundOptions) => CSSObject;
35
+ aurora: (options?: AuroraBackgroundOptions) => CSSObject;
36
+ mesh: (options?: MeshBackgroundOptions) => CSSObject;
37
+ grid: (options?: GridBackgroundOptions) => CSSObject;
38
+ spotlight: (options?: SpotlightBackgroundOptions) => CSSObject;
39
+ }
40
+ export type Backgrounds = BackgroundVariants;
41
+ export interface ThemeColors {
42
+ 'accent-1': string;
43
+ 'accent-2': string;
44
+ 'accent-3': string;
45
+ light: string;
46
+ textLight: string;
47
+ dark: string;
48
+ textDark: string;
49
+ neutral: string;
50
+ muted: string;
51
+ }
52
+ export type ShadowLevel = 'low' | 'medium' | 'high';
53
+ export interface SpacingScale {
54
+ tight: number;
55
+ compact: number;
56
+ half: number;
57
+ comfortable: number;
58
+ base: number;
59
+ double: number;
60
+ }
61
+ export interface RadiusScale {
62
+ quarter: number;
63
+ half: number;
64
+ base: number;
65
+ double: number;
66
+ }
67
+ export interface SlidesTheme {
68
+ width: number;
69
+ height: number;
70
+ fontFamily: string;
71
+ fontSize: number;
72
+ spacing: number;
73
+ radius: number;
74
+ spacings: SpacingScale;
75
+ radii: RadiusScale;
76
+ colors: ThemeColors;
77
+ backgrounds: Backgrounds;
78
+ shadows: Record<ShadowLevel, CSSObject>;
79
+ typography: CSSObject;
80
+ motion: {
81
+ duration: number;
82
+ easing: string;
83
+ };
84
+ }
85
+ export type SlidesThemeInput = Partial<Omit<SlidesTheme, 'colors' | 'motion' | 'backgrounds' | 'shadows' | 'spacings' | 'radii'>> & {
86
+ backgrounds?: Partial<Backgrounds>;
87
+ shadows?: Partial<SlidesTheme['shadows']>;
88
+ colors?: Partial<SlidesTheme['colors']>;
89
+ motion?: Partial<SlidesTheme['motion']>;
90
+ };
91
+ /** Resolve every field, deriving background factories from the final palette. */
92
+ export declare function resolveSlidesTheme(input?: SlidesThemeInput): SlidesTheme;
93
+ /** Nested providers inherit raw overrides so derived colors remain reactive. */
94
+ export declare function SlidesThemeProvider({ theme, children }: {
95
+ theme?: SlidesThemeInput;
96
+ children: ReactNode;
97
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
98
+ export declare function useSlidesTheme(): SlidesTheme;
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "rlz-web-slides",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/rlz/web-slides.git"
7
+ },
8
+ "homepage": "https://rlz.github.io/web-slides/",
9
+ "license": "MIT",
10
+ "keywords": [
11
+ "react",
12
+ "slides",
13
+ "presentation",
14
+ "emotion"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.12.0"
18
+ },
19
+ "type": "module",
20
+ "scripts": {
21
+ "dev": "vite",
22
+ "build": "tsc -b && vite build",
23
+ "build:pages": "tsc -b && vite build --mode pages",
24
+ "export:html": "node scripts/export-demo.mjs",
25
+ "lint": "oxlint && prettier --check .",
26
+ "format": "prettier --write .",
27
+ "preview": "vite preview",
28
+ "start": "vite",
29
+ "demo": "vite --mode demo",
30
+ "build:lib": "vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json",
31
+ "test": "npm run build:lib && node --test tests/*.test.mjs",
32
+ "prepack": "npm run build:lib"
33
+ },
34
+ "dependencies": {
35
+ "@fortawesome/fontawesome-svg-core": "^7.3.1",
36
+ "@fortawesome/free-solid-svg-icons": "^7.3.1",
37
+ "@fortawesome/react-fontawesome": "^3.5.0",
38
+ "@vitejs/plugin-react": "^6.0.4",
39
+ "colord": "^2.10.0",
40
+ "culori": "^4.0.2",
41
+ "vite": "^8.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "@emotion/react": "^11.14.0",
45
+ "@types/culori": "^4.0.1",
46
+ "@types/node": "^26.3.0",
47
+ "@types/react": "^19.2.17",
48
+ "@types/react-dom": "^19.2.3",
49
+ "oxlint": "^1.75.0",
50
+ "prettier": "^3.9.6",
51
+ "react": "^19.2.8",
52
+ "react-dom": "^19.2.8",
53
+ "typescript": "^7.0.2"
54
+ },
55
+ "description": "Composable React slides, themes, an embedded player and standalone HTML export",
56
+ "main": "./lib/index.js",
57
+ "types": "./lib/index.d.ts",
58
+ "exports": {
59
+ ".": {
60
+ "types": "./lib/index.d.ts",
61
+ "import": "./lib/index.js"
62
+ },
63
+ "./build": {
64
+ "types": "./scripts/build-slides.d.mts",
65
+ "import": "./scripts/build-slides.mjs"
66
+ }
67
+ },
68
+ "files": [
69
+ "lib",
70
+ "scripts/build-slides.mjs",
71
+ "scripts/build-slides.d.mts",
72
+ "README.md",
73
+ "LICENSE"
74
+ ],
75
+ "peerDependencies": {
76
+ "@emotion/react": "^11.14.0",
77
+ "react": "^19.2.8",
78
+ "react-dom": "^19.2.8"
79
+ }
80
+ }
@@ -0,0 +1,17 @@
1
+ import type { SlidesThemeInput } from '../lib/theme'
2
+
3
+ export interface SlideModule {
4
+ file: string
5
+ exportName?: string
6
+ props?: Record<string, unknown>
7
+ }
8
+ export interface BuildSlidesOptions {
9
+ title?: string
10
+ theme?: SlidesThemeInput
11
+ publicDir?: string
12
+ }
13
+ export function buildSlides(
14
+ slides: (string | SlideModule)[],
15
+ filename: string,
16
+ options?: BuildSlidesOptions
17
+ ): Promise<string>
@@ -0,0 +1,165 @@
1
+ import { build } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
4
+ import { dirname, extname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const libraryEntry = fileURLToPath(new URL('../lib/index.js', import.meta.url))
8
+ const mimeTypes = {
9
+ '.png': 'image/png',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.svg': 'image/svg+xml',
13
+ '.gif': 'image/gif',
14
+ '.webp': 'image/webp',
15
+ '.avif': 'image/avif',
16
+ '.ico': 'image/x-icon',
17
+ '.otf': 'font/otf',
18
+ '.ttf': 'font/ttf',
19
+ '.woff': 'font/woff',
20
+ '.woff2': 'font/woff2',
21
+ '.mp4': 'video/mp4',
22
+ '.webm': 'video/webm',
23
+ '.mp3': 'audio/mpeg',
24
+ '.wav': 'audio/wav',
25
+ '.pdf': 'application/pdf'
26
+ }
27
+
28
+ /**
29
+ * Bundle React slide modules into a standalone, interactive HTML file.
30
+ * @param {(string | {file: string, exportName?: string, props?: Record<string, unknown>})[]} slides
31
+ * @param {string} filename Output path, with or without .html.
32
+ * @param {{title?: string, theme?: import('../lib/theme').SlidesThemeInput, publicDir?: string}} options
33
+ * @returns {Promise<string>} Absolute output path.
34
+ */
35
+ export async function buildSlides(slides, filename, options = {}) {
36
+ if (!Array.isArray(slides) || slides.length === 0)
37
+ throw new TypeError('Pass at least one slide module')
38
+ if (typeof filename !== 'string' || !filename.trim())
39
+ throw new TypeError('Pass a non-empty filename')
40
+ const root = process.cwd()
41
+ const descriptors = slides.map((slide) =>
42
+ typeof slide === 'string' ? { file: slide } : slide
43
+ )
44
+ for (const slide of descriptors) {
45
+ if (!slide || typeof slide.file !== 'string' || !slide.file.trim())
46
+ throw new TypeError('Every slide needs a module file path')
47
+ }
48
+ const publicDir = resolve(root, options.publicDir ?? 'public')
49
+ const entryId = resolve(root, '.web-slides-entry.tsx')
50
+ const source = [
51
+ 'import { createElement } from "react"',
52
+ 'import { createRoot } from "react-dom/client"',
53
+ `import { Presentation, SlidesThemeProvider } from ${JSON.stringify(libraryEntry)}`,
54
+ ...descriptors.map(
55
+ (slide, index) =>
56
+ `import { ${JSON.stringify(slide.exportName ?? 'default')} as Slide${index} } from ${JSON.stringify(resolve(root, slide.file))}`
57
+ ),
58
+ `const slides = [${descriptors.map((slide, index) => `createElement(Slide${index}, ${JSON.stringify(slide.props ?? {})})`).join(',')}]`,
59
+ `createRoot(document.getElementById('root')).render(createElement(SlidesThemeProvider, {theme: ${JSON.stringify(options.theme ?? {})}}, createElement(Presentation, {slides, mode: 'fullscreen'})))`
60
+ ].join('\n')
61
+ const result = await build({
62
+ configFile: false,
63
+ root,
64
+ publicDir,
65
+ plugins: [
66
+ {
67
+ name: 'web-slides-entry',
68
+ resolveId(id) {
69
+ if (id === entryId) return entryId
70
+ },
71
+ load(id) {
72
+ if (id === entryId) return source
73
+ }
74
+ },
75
+ react({ jsxImportSource: '@emotion/react' })
76
+ ],
77
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') },
78
+ resolve: { dedupe: ['react', 'react-dom', '@emotion/react'] },
79
+ build: {
80
+ write: false,
81
+ copyPublicDir: false,
82
+ minify: false,
83
+ lib: {
84
+ entry: entryId,
85
+ name: 'WebSlidesPresentation',
86
+ formats: ['iife']
87
+ }
88
+ }
89
+ })
90
+ const outputs = (Array.isArray(result) ? result : [result]).flatMap(
91
+ (bundle) => bundle.output
92
+ )
93
+ let javascript = outputs
94
+ .filter((output) => output.type === 'chunk')
95
+ .map((output) => output.code)
96
+ .join('\n')
97
+ let css = outputs
98
+ .filter(
99
+ (output) =>
100
+ output.type === 'asset' && output.fileName.endsWith('.css')
101
+ )
102
+ .map((output) => String(output.source))
103
+ .join('\n')
104
+ for (const output of outputs) {
105
+ if (output.type === 'asset' && !output.fileName.endsWith('.css'))
106
+ throw new Error(`Asset could not be inlined: ${output.fileName}`)
107
+ }
108
+ // Vite library mode inlines imported assets. Public assets need explicit embedding.
109
+ for (const file of await publicFiles(publicDir)) {
110
+ const url = `/${file}`
111
+ const escaped = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
112
+ const pattern = new RegExp(
113
+ /(?<=["'`(])/.source + escaped + /(?=["'`)])/.source,
114
+ 'g'
115
+ )
116
+ if (!javascript.match(pattern) && !css.match(pattern)) continue
117
+ const mime = mimeTypes[extname(file).toLowerCase()]
118
+ if (!mime)
119
+ throw new Error(
120
+ `Import this resource from source instead of public: ${file}`
121
+ )
122
+ const data = `data:${mime};base64,${(await readFile(resolve(publicDir, file))).toString('base64')}`
123
+ javascript = javascript.replace(pattern, () => data)
124
+ css = css.replace(pattern, () => data)
125
+ }
126
+ const title = (options.title ?? 'Presentation').replace(
127
+ /[&<>"']/g,
128
+ (character) =>
129
+ ({
130
+ '&': '&amp;',
131
+ '<': '&lt;',
132
+ '>': '&gt;',
133
+ '"': '&quot;',
134
+ "'": '&#39;'
135
+ })[character]
136
+ )
137
+ const html = `<!doctype html>\n<html lang="ru">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${title}</title>\n<style>body { margin: 0; background: #171c17; }\n${css.replace(/<\/style/gi, '<\\/style')}</style>\n</head>\n<body>\n<div id="root"></div>\n<script>${javascript.replace(/<\/script/gi, '<\\/script')}</script>\n</body>\n</html>\n`
138
+ const outputPath = resolve(
139
+ root,
140
+ filename.endsWith('.html') ? filename : `${filename}.html`
141
+ )
142
+ await mkdir(dirname(outputPath), { recursive: true })
143
+ await writeFile(outputPath, html)
144
+ return outputPath
145
+ }
146
+
147
+ async function publicFiles(directory, prefix = '') {
148
+ let entries
149
+ try {
150
+ entries = await readdir(resolve(directory, prefix), {
151
+ withFileTypes: true
152
+ })
153
+ } catch (error) {
154
+ if (error.code === 'ENOENT') return []
155
+ throw error
156
+ }
157
+ const result = []
158
+ for (const entry of entries) {
159
+ const file = prefix ? `${prefix}/${entry.name}` : entry.name
160
+ if (entry.isDirectory())
161
+ result.push(...(await publicFiles(directory, file)))
162
+ else if (entry.isFile()) result.push(file)
163
+ }
164
+ return result.sort((a, b) => b.length - a.length)
165
+ }