@emirotin/zerp 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/AGENTS.md ADDED
@@ -0,0 +1,28 @@
1
+ # AGENTS.md
2
+
3
+ ## Repository Purpose
4
+
5
+ `zerp` is the reusable presentation framework extracted from slide decks. The framework owns assembly, default styles, runtime navigation, serving, build output, and package metadata. Presentations should be able to live as a `slides/` folder plus package-level dependency wiring outside this repo.
6
+
7
+ ## Working Rules
8
+
9
+ - Keep the authored deck contract minimal: `slides/**/*.html` plus optional assets under `slides/`.
10
+ - Prefer zero-config behavior over introducing per-deck files.
11
+ - Preserve support for inline interactive slide scripts.
12
+ - Keep the package ready for both local `file:` installs and registry publishing.
13
+ - Default styles should stay useful out of the box, but generic enough for reuse.
14
+ - Treat `dist/` as tracked release output. Source edits belong under `src/` and `scripts/`; `dist/` is regenerated by the build.
15
+ - The default browser runtime and CSS live in `src/assets/` and are inlined by `src/presentation.ts` during `serve` and `build`.
16
+ - `examples/**/index.html` is generated and should not be edited by hand.
17
+ - Pre-commit rebuilds `dist/`, so source and generated output must stay in sync.
18
+
19
+ ## Commands
20
+
21
+ ```bash
22
+ pnpm install
23
+ pnpm build
24
+ pnpm check
25
+ pnpm lint
26
+ pnpm format:check
27
+ pnpm exec zerp serve examples/casino
28
+ ```
package/CLAUDE.md ADDED
@@ -0,0 +1,39 @@
1
+ # CLAUDE.md
2
+
3
+ This repository contains the `zerp` presentation framework.
4
+
5
+ ## Architecture
6
+
7
+ - `src/index.ts` exposes the framework API.
8
+ - `src/cli.ts` provides the publishable CLI.
9
+ - `src/presentation.ts` assembles slide files into one HTML document.
10
+ - `src/server.ts` serves a deck and static assets from a target directory.
11
+ - `src/assets/default-styles.css` contains the default presentation styles.
12
+ - `src/assets/default-runtime.js` contains the browser navigation/runtime logic.
13
+ - `scripts/build.mjs` builds TypeScript output into `dist/`, copies assets, and formats generated files.
14
+ - `dist/` is tracked and represents the publishable package contents.
15
+
16
+ ## Deck Contract
17
+
18
+ - A deck is discovered from a directory containing `slides/`.
19
+ - `slides/**/*.html` files are ordered lexicographically.
20
+ - Non-HTML assets can live anywhere under `slides/`.
21
+ - Relative asset references inside slide HTML are rewritten relative to the deck root, so slide-local paths continue to work after assembly.
22
+ - Generated `index.html` output is not source-of-truth for example decks.
23
+
24
+ ## Tooling
25
+
26
+ - Node and pnpm are pinned in `package.json` via Volta metadata and `packageManager`.
27
+ - `oxlint` and `oxfmt` are the standard lint/format tools.
28
+ - `husky` runs `lint-staged`, rebuilds `dist/`, and stages regenerated output on every commit.
29
+ - `.zed/settings.json` and `.zed/format.sh` wire Zed to `oxfmt`.
30
+
31
+ ## Development
32
+
33
+ ```bash
34
+ pnpm install
35
+ pnpm build
36
+ pnpm check
37
+ pnpm lint
38
+ pnpm format:check
39
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Mirotin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # zerp
2
+
3
+ `zerp` is a zero-config HTML presentation framework.
4
+
5
+ Each presentation can be authored as just a `slides/` folder:
6
+
7
+ ```text
8
+ my-deck/
9
+ slides/
10
+ 00-title.html
11
+ 10-intro.html
12
+ images/
13
+ cover.jpg
14
+ ```
15
+
16
+ `zerp` finds `slides/**/*.html`, sorts them by filename, rewrites relative asset URLs so slide-local assets keep working, injects default styles/runtime, and serves or builds a single-page deck.
17
+
18
+ ## Maintainer Policy
19
+
20
+ I use `zerp` myself and find it useful, which is why I am making it public as free open-source software.
21
+
22
+ That does not mean I am available for general collaboration. Issues and pull requests are intentionally disabled. I do not have the capacity to debug other people's problems for free, and I do not want to spend time triaging low-signal or AI-generated contributions.
23
+
24
+ If you want to use the project as-is, please do. If you need a fix, a feature, or help integrating it into your workflow, contact me directly for paid support.
25
+
26
+ ## Usage
27
+
28
+ Install from a local checkout:
29
+
30
+ ```bash
31
+ pnpm add -D file:../zerp
32
+ pnpm exec zerp serve .
33
+ ```
34
+
35
+ Or from a registry:
36
+
37
+ ```bash
38
+ pnpm add -D @emirotin/zerp
39
+ pnpm exec zerp build .
40
+ ```
41
+
42
+ Commands:
43
+
44
+ ```bash
45
+ pnpm exec zerp serve # serve the current deck on http://localhost:8000
46
+ pnpm exec zerp serve 3000 # current deck, custom port
47
+ pnpm exec zerp serve . 3000 # explicit deck dir
48
+ pnpm exec zerp build # write ./index.html for the current deck
49
+ ```
50
+
51
+ ## Tooling
52
+
53
+ This repo pins Node and pnpm via Volta metadata in `package.json`:
54
+
55
+ ```bash
56
+ volta pin node@24.14.1 pnpm@10.33.0
57
+ ```
58
+
59
+ Quality commands:
60
+
61
+ ```bash
62
+ pnpm lint
63
+ pnpm lint:fix
64
+ pnpm format
65
+ pnpm format:check
66
+ ```
67
+
68
+ `husky` runs `lint-staged`, rebuilds `dist/`, and stages the rebuilt package output before each commit.
69
+
70
+ ## Authoring
71
+
72
+ - Put all authored content in `slides/`.
73
+ - Use filename prefixes for ordering, for example `00-`, `10-`, `20-`.
74
+ - Store deck assets under `slides/` too. Relative links like `src="./images/foo.jpg"` are rewritten automatically.
75
+ - Each `.html` file can contain one or more `<div class="slide">` blocks.
76
+ - The framework default CSS and browser runtime are stored as separate source assets and inlined into generated HTML during `serve` and `build`.
77
+
78
+ ## Library API
79
+
80
+ ```ts
81
+ import { buildPresentationHtml, writePresentation } from "@emirotin/zerp";
82
+ ```
83
+
84
+ ## Example
85
+
86
+ This repository includes a migrated example deck at `examples/casino/`. Its authored source is only `examples/casino/slides/`.
@@ -0,0 +1,93 @@
1
+ (() => {
2
+ const slides = Array.from(document.querySelectorAll(".slide"));
3
+ let current = 0;
4
+ const total = slides.length;
5
+ const counter = document.getElementById("counter");
6
+ const progress = document.getElementById("progress");
7
+
8
+ function clamp(index) {
9
+ return Math.max(0, Math.min(index, total - 1));
10
+ }
11
+
12
+ function show(index) {
13
+ current = clamp(index);
14
+ for (const slide of slides) {
15
+ slide.classList.remove("active");
16
+ }
17
+ const active = slides[current];
18
+ if (!active) {
19
+ return;
20
+ }
21
+ active.classList.add("active");
22
+ if (counter) {
23
+ counter.textContent = String(current + 1) + " / " + String(total);
24
+ }
25
+ if (progress) {
26
+ progress.style.width = String(((current + 1) / Math.max(total, 1)) * 100) + "%";
27
+ }
28
+ history.replaceState(null, "", "#" + String(current + 1));
29
+ }
30
+
31
+ function next() {
32
+ show(current + 1);
33
+ }
34
+
35
+ function prev() {
36
+ show(current - 1);
37
+ }
38
+
39
+ function stepForward() {
40
+ slides[current]?.dispatchEvent(new Event("slide-next"));
41
+ }
42
+
43
+ function stepBackward() {
44
+ slides[current]?.dispatchEvent(new Event("slide-prev"));
45
+ }
46
+
47
+ window.next = next;
48
+ window.prev = prev;
49
+
50
+ document.addEventListener("keydown", (event) => {
51
+ if (event.key === "ArrowRight" || event.key === " " || event.key === "PageDown") {
52
+ event.preventDefault();
53
+ next();
54
+ }
55
+ if (event.key === "ArrowLeft" || event.key === "PageUp") {
56
+ event.preventDefault();
57
+ prev();
58
+ }
59
+ if (event.key === "Home") {
60
+ event.preventDefault();
61
+ show(0);
62
+ }
63
+ if (event.key === "End") {
64
+ event.preventDefault();
65
+ show(total - 1);
66
+ }
67
+ if (event.key === "ArrowDown") {
68
+ event.preventDefault();
69
+ stepForward();
70
+ }
71
+ if (event.key === "ArrowUp") {
72
+ event.preventDefault();
73
+ stepBackward();
74
+ }
75
+ });
76
+
77
+ let touchStartX = 0;
78
+ document.addEventListener("touchstart", (event) => {
79
+ touchStartX = event.touches[0]?.clientX ?? 0;
80
+ });
81
+ document.addEventListener("touchend", (event) => {
82
+ const delta = touchStartX - (event.changedTouches[0]?.clientX ?? 0);
83
+ if (Math.abs(delta) > 60) {
84
+ if (delta > 0) {
85
+ next();
86
+ } else {
87
+ prev();
88
+ }
89
+ }
90
+ });
91
+
92
+ show((Number.parseInt(location.hash.slice(1), 10) || 1) - 1);
93
+ })();
@@ -0,0 +1,294 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ margin: 0;
4
+ padding: 0;
5
+ }
6
+
7
+ :root {
8
+ --zerp-bg: #0d1117;
9
+ --zerp-panel: #161b22;
10
+ --zerp-border: #30363d;
11
+ --zerp-text: #e6edf3;
12
+ --zerp-muted: #8b949e;
13
+ --zerp-faint: #484f58;
14
+ --zerp-accent: #58a6ff;
15
+ --zerp-green: #3fb950;
16
+ --zerp-orange: #f0883e;
17
+ --zerp-purple: #bc8cff;
18
+ --zerp-red: #f85149;
19
+ }
20
+
21
+ html,
22
+ body {
23
+ width: 100%;
24
+ height: 100%;
25
+ overflow: hidden;
26
+ background: var(--zerp-bg);
27
+ color: var(--zerp-text);
28
+ font-family: "Montserrat", sans-serif;
29
+ }
30
+
31
+ img,
32
+ video {
33
+ max-width: 100%;
34
+ }
35
+
36
+ .slide {
37
+ display: none;
38
+ width: 100vw;
39
+ height: 100vh;
40
+ padding: 50px 70px;
41
+ flex-direction: column;
42
+ justify-content: center;
43
+ position: relative;
44
+ }
45
+
46
+ .slide.active {
47
+ display: flex;
48
+ }
49
+
50
+ .slide h1 {
51
+ font-size: 3.2em;
52
+ font-weight: 900;
53
+ line-height: 1.15;
54
+ margin-bottom: 20px;
55
+ }
56
+
57
+ .slide h2 {
58
+ font-size: 2.2em;
59
+ font-weight: 700;
60
+ line-height: 1.2;
61
+ margin-bottom: 14px;
62
+ }
63
+
64
+ .slide h3 {
65
+ font-size: 1.4em;
66
+ font-weight: 700;
67
+ margin-bottom: 10px;
68
+ color: var(--zerp-muted);
69
+ }
70
+
71
+ .slide p,
72
+ .slide li {
73
+ font-size: 1.25em;
74
+ line-height: 1.5;
75
+ color: #c9d1d9;
76
+ }
77
+
78
+ .slide ul {
79
+ list-style: none;
80
+ padding: 0;
81
+ }
82
+
83
+ .slide li {
84
+ padding: 5px 0;
85
+ }
86
+
87
+ .slide li::before {
88
+ content: "→ ";
89
+ color: var(--zerp-accent);
90
+ }
91
+
92
+ .accent {
93
+ color: var(--zerp-accent);
94
+ }
95
+
96
+ .accent-green {
97
+ color: var(--zerp-green);
98
+ }
99
+
100
+ .accent-orange {
101
+ color: var(--zerp-orange);
102
+ }
103
+
104
+ .accent-purple {
105
+ color: var(--zerp-purple);
106
+ }
107
+
108
+ .accent-red {
109
+ color: var(--zerp-red);
110
+ }
111
+
112
+ .big-number {
113
+ font-size: 3em;
114
+ font-weight: 900;
115
+ color: var(--zerp-accent);
116
+ font-family: "Roboto Mono", monospace;
117
+ }
118
+
119
+ .img-row {
120
+ display: flex;
121
+ gap: 24px;
122
+ align-items: center;
123
+ justify-content: center;
124
+ margin: 24px 0;
125
+ flex-wrap: wrap;
126
+ }
127
+
128
+ .img-row img {
129
+ max-height: 300px;
130
+ border-radius: 10px;
131
+ object-fit: contain;
132
+ background: var(--zerp-panel);
133
+ padding: 6px;
134
+ }
135
+
136
+ .two-col {
137
+ display: grid;
138
+ grid-template-columns: 1fr 1fr;
139
+ gap: 40px;
140
+ align-items: center;
141
+ }
142
+
143
+ .caption {
144
+ font-size: 0.8em;
145
+ color: var(--zerp-muted);
146
+ text-align: center;
147
+ margin-top: 4px;
148
+ }
149
+
150
+ .block-label {
151
+ position: absolute;
152
+ top: 24px;
153
+ left: 70px;
154
+ font-size: 0.8em;
155
+ font-weight: 700;
156
+ color: var(--zerp-faint);
157
+ text-transform: uppercase;
158
+ letter-spacing: 3px;
159
+ }
160
+
161
+ .interactive-badge {
162
+ display: inline-block;
163
+ background: #238636;
164
+ color: white;
165
+ padding: 5px 16px;
166
+ border-radius: 20px;
167
+ font-size: 0.8em;
168
+ font-weight: 700;
169
+ margin-bottom: 14px;
170
+ }
171
+
172
+ .timeline {
173
+ display: flex;
174
+ gap: 20px;
175
+ flex-wrap: wrap;
176
+ justify-content: center;
177
+ margin: 16px 0;
178
+ }
179
+
180
+ .timeline .item {
181
+ background: var(--zerp-panel);
182
+ border: 1px solid var(--zerp-border);
183
+ border-radius: 10px;
184
+ padding: 14px 18px;
185
+ text-align: center;
186
+ min-width: 130px;
187
+ }
188
+
189
+ .timeline .item .year {
190
+ font-family: "Roboto Mono", monospace;
191
+ font-size: 1.3em;
192
+ font-weight: 700;
193
+ color: var(--zerp-accent);
194
+ }
195
+
196
+ .timeline .item .label {
197
+ font-size: 0.85em;
198
+ color: #c9d1d9;
199
+ margin-top: 4px;
200
+ }
201
+
202
+ .nav {
203
+ position: fixed;
204
+ bottom: 24px;
205
+ right: 36px;
206
+ display: flex;
207
+ gap: 10px;
208
+ z-index: 100;
209
+ }
210
+
211
+ .nav button {
212
+ background: none;
213
+ border: none;
214
+ color: var(--zerp-faint);
215
+ padding: 4px 8px;
216
+ cursor: pointer;
217
+ font-size: 0.85em;
218
+ font-family: "Roboto Mono", monospace;
219
+ }
220
+
221
+ .nav button:hover {
222
+ color: var(--zerp-muted);
223
+ }
224
+
225
+ .counter {
226
+ position: fixed;
227
+ bottom: 28px;
228
+ left: 36px;
229
+ font-size: 0.85em;
230
+ color: var(--zerp-faint);
231
+ font-family: "Roboto Mono", monospace;
232
+ z-index: 100;
233
+ }
234
+
235
+ .progress {
236
+ position: fixed;
237
+ top: 0;
238
+ left: 0;
239
+ height: 3px;
240
+ background: var(--zerp-accent);
241
+ z-index: 100;
242
+ transition: width 0.3s;
243
+ }
244
+
245
+ .quote {
246
+ border-left: 4px solid var(--zerp-accent);
247
+ padding: 14px 20px;
248
+ margin: 16px 0;
249
+ background: var(--zerp-panel);
250
+ border-radius: 0 10px 10px 0;
251
+ font-style: italic;
252
+ font-size: 1.1em;
253
+ }
254
+
255
+ .key-thought {
256
+ background: var(--zerp-panel);
257
+ border: 2px solid var(--zerp-accent);
258
+ border-radius: 14px;
259
+ padding: 28px 36px;
260
+ margin: 16px 0;
261
+ text-align: center;
262
+ }
263
+
264
+ .key-thought p {
265
+ font-size: 1.4em;
266
+ font-weight: 700;
267
+ color: var(--zerp-text);
268
+ }
269
+
270
+ .grid-demo {
271
+ display: grid;
272
+ grid-template-columns: repeat(7, 48px);
273
+ gap: 3px;
274
+ justify-content: center;
275
+ margin: 16px 0;
276
+ }
277
+
278
+ .grid-demo .cell {
279
+ width: 48px;
280
+ height: 48px;
281
+ border: 2px solid var(--zerp-border);
282
+ border-radius: 5px;
283
+ display: flex;
284
+ align-items: center;
285
+ justify-content: center;
286
+ font-size: 0.75em;
287
+ color: var(--zerp-faint);
288
+ }
289
+
290
+ .grid-demo .cell.filled {
291
+ background: var(--zerp-red);
292
+ border-color: var(--zerp-red);
293
+ color: white;
294
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { writePresentation } from "./presentation.js";
4
+ import { servePresentation } from "./server.js";
5
+ function printUsage() {
6
+ process.stderr.write(
7
+ [
8
+ "Usage:",
9
+ " zerp serve [deck-dir] [port]",
10
+ " zerp build [deck-dir]",
11
+ "",
12
+ "A deck directory must contain slides/.",
13
+ "",
14
+ ].join("\n"),
15
+ );
16
+ }
17
+ async function main() {
18
+ const [, , command, firstArg, secondArg] = process.argv;
19
+ if (!command) {
20
+ printUsage();
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ if (command === "build") {
25
+ const rootDir = path.resolve(firstArg ?? ".");
26
+ const outFile = await writePresentation({ rootDir });
27
+ process.stdout.write(`Wrote ${outFile}\n`);
28
+ return;
29
+ }
30
+ if (command === "serve") {
31
+ const hasExplicitDeckDir = firstArg !== undefined && !/^\d+$/.test(firstArg);
32
+ const rootDir = path.resolve(hasExplicitDeckDir ? firstArg : ".");
33
+ const portArg = hasExplicitDeckDir ? secondArg : firstArg;
34
+ const port = portArg ? Number.parseInt(portArg, 10) : 8000;
35
+ if (!Number.isInteger(port)) {
36
+ throw new Error(`Invalid port: ${portArg}`);
37
+ }
38
+ await servePresentation(rootDir, port);
39
+ return;
40
+ }
41
+ printUsage();
42
+ process.exitCode = 1;
43
+ }
44
+ main().catch((error) => {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ process.stderr.write(`${message}\n`);
47
+ process.exitCode = 1;
48
+ });
@@ -0,0 +1,3 @@
1
+ export { buildPresentationHtml, listSlides, writePresentation } from "./presentation.js";
2
+ export type { BuildOptions } from "./presentation.js";
3
+ export { servePresentation } from "./server.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { buildPresentationHtml, listSlides, writePresentation } from "./presentation.js";
2
+ export { servePresentation } from "./server.js";
@@ -0,0 +1,14 @@
1
+ export interface BuildOptions {
2
+ rootDir: string;
3
+ title?: string;
4
+ lang?: string;
5
+ outFile?: string;
6
+ }
7
+ interface SlideFile {
8
+ absolutePath: string;
9
+ relativePath: string;
10
+ }
11
+ export declare function listSlides(rootDir: string): Promise<SlideFile[]>;
12
+ export declare function buildPresentationHtml(options: BuildOptions): Promise<string>;
13
+ export declare function writePresentation(options: BuildOptions): Promise<string>;
14
+ export {};
@@ -0,0 +1,114 @@
1
+ import { readdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const assetCache = new Map();
4
+ const URL_ATTRS = ["src", "href", "poster"];
5
+ function escapeHtml(value) {
6
+ return value
7
+ .replaceAll("&", "&amp;")
8
+ .replaceAll("<", "&lt;")
9
+ .replaceAll(">", "&gt;")
10
+ .replaceAll('"', "&quot;");
11
+ }
12
+ function isExternalUrl(value) {
13
+ return /^(?:[a-z]+:|#|\/)/i.test(value);
14
+ }
15
+ function rewriteRelativeUrls(html, relativeSlidePath) {
16
+ const slideRootRelativePath = path.posix.join(
17
+ "slides",
18
+ relativeSlidePath.replaceAll(path.sep, "/"),
19
+ );
20
+ const slideDir = path.posix.dirname(slideRootRelativePath);
21
+ const normalizedDir = slideDir === "." ? "" : slideDir;
22
+ return html.replace(
23
+ /\b(src|href|poster)\s*=\s*(["'])([^"']+)\2/gi,
24
+ (_match, attr, quote, value) => {
25
+ if (!URL_ATTRS.includes(attr.toLowerCase()) || isExternalUrl(value)) {
26
+ return `${attr}=${quote}${value}${quote}`;
27
+ }
28
+ const rewritten = path.posix.normalize(
29
+ normalizedDir ? path.posix.join(normalizedDir, value) : value,
30
+ );
31
+ return `${attr}=${quote}${rewritten}${quote}`;
32
+ },
33
+ );
34
+ }
35
+ function readAsset(assetPath) {
36
+ const cached = assetCache.get(assetPath);
37
+ if (cached) {
38
+ return cached;
39
+ }
40
+ const contentPromise = readFile(new URL(assetPath, import.meta.url), "utf8");
41
+ assetCache.set(assetPath, contentPromise);
42
+ return contentPromise;
43
+ }
44
+ async function collectSlideFiles(slidesDir, prefix = "") {
45
+ const entries = await readdir(slidesDir, { withFileTypes: true });
46
+ const files = await Promise.all(
47
+ entries
48
+ .filter((entry) => !entry.name.startsWith("."))
49
+ .sort((left, right) => left.name.localeCompare(right.name))
50
+ .map(async (entry) => {
51
+ const relativePath = path.join(prefix, entry.name);
52
+ const absolutePath = path.join(slidesDir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ return collectSlideFiles(absolutePath, relativePath);
55
+ }
56
+ if (entry.isFile() && entry.name.endsWith(".html")) {
57
+ return [{ absolutePath, relativePath }];
58
+ }
59
+ return [];
60
+ }),
61
+ );
62
+ return files.flat();
63
+ }
64
+ export async function listSlides(rootDir) {
65
+ const slidesDir = path.join(rootDir, "slides");
66
+ return collectSlideFiles(slidesDir);
67
+ }
68
+ export async function buildPresentationHtml(options) {
69
+ const title = options.title ?? path.basename(path.resolve(options.rootDir));
70
+ const lang = options.lang ?? "en";
71
+ const slideFiles = await listSlides(options.rootDir);
72
+ const [defaultStyles, defaultRuntime] = await Promise.all([
73
+ readAsset("./assets/default-styles.css"),
74
+ readAsset("./assets/default-runtime.js"),
75
+ ]);
76
+ if (slideFiles.length === 0) {
77
+ throw new Error(`No slide HTML files found in ${path.join(options.rootDir, "slides")}`);
78
+ }
79
+ const slideHtmlParts = await Promise.all(
80
+ slideFiles.map(async ({ absolutePath, relativePath }) => {
81
+ const html = await readFile(absolutePath, "utf8");
82
+ return rewriteRelativeUrls(html, relativePath);
83
+ }),
84
+ );
85
+ return [
86
+ "<!doctype html>",
87
+ `<html lang="${escapeHtml(lang)}">`,
88
+ " <head>",
89
+ ' <meta charset="UTF-8" />',
90
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0" />',
91
+ ` <title>${escapeHtml(title)}</title>`,
92
+ ' <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700;900&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet" />',
93
+ " <style>",
94
+ defaultStyles,
95
+ " </style>",
96
+ " </head>",
97
+ " <body>",
98
+ slideHtmlParts.join("\n"),
99
+ ' <div class="progress" id="progress"></div>',
100
+ ' <div class="counter" id="counter"></div>',
101
+ ' <div class="nav"><button onclick="prev()">←</button><button onclick="next()">→</button></div>',
102
+ " <script>",
103
+ defaultRuntime,
104
+ " </script>",
105
+ " </body>",
106
+ "</html>",
107
+ ].join("\n");
108
+ }
109
+ export async function writePresentation(options) {
110
+ const outFile = options.outFile ?? path.join(options.rootDir, "index.html");
111
+ const html = await buildPresentationHtml(options);
112
+ await writeFile(outFile, html, "utf8");
113
+ return outFile;
114
+ }
@@ -0,0 +1 @@
1
+ export declare function servePresentation(rootDir: string, port: number): Promise<void>;
package/dist/server.js ADDED
@@ -0,0 +1,52 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { buildPresentationHtml } from "./presentation.js";
5
+ const CONTENT_TYPES = new Map([
6
+ [".css", "text/css; charset=utf-8"],
7
+ [".gif", "image/gif"],
8
+ [".html", "text/html; charset=utf-8"],
9
+ [".jpeg", "image/jpeg"],
10
+ [".jpg", "image/jpeg"],
11
+ [".js", "text/javascript; charset=utf-8"],
12
+ [".json", "application/json; charset=utf-8"],
13
+ [".png", "image/png"],
14
+ [".svg", "image/svg+xml"],
15
+ [".webp", "image/webp"],
16
+ ]);
17
+ function getContentType(filePath) {
18
+ return CONTENT_TYPES.get(path.extname(filePath).toLowerCase()) ?? "application/octet-stream";
19
+ }
20
+ export async function servePresentation(rootDir, port) {
21
+ const resolvedRoot = path.resolve(rootDir);
22
+ const host = "127.0.0.1";
23
+ const server = createServer(async (req, res) => {
24
+ try {
25
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
26
+ const pathname = decodeURIComponent(requestUrl.pathname);
27
+ if (pathname === "/" || pathname === "/index.html") {
28
+ const html = await buildPresentationHtml({ rootDir: resolvedRoot });
29
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
30
+ res.end(html);
31
+ return;
32
+ }
33
+ const candidatePath = path.resolve(resolvedRoot, `.${pathname}`);
34
+ if (!candidatePath.startsWith(resolvedRoot)) {
35
+ res.writeHead(403);
36
+ res.end("Forbidden");
37
+ return;
38
+ }
39
+ const content = await readFile(candidatePath);
40
+ res.writeHead(200, { "content-type": getContentType(candidatePath) });
41
+ res.end(content);
42
+ } catch (error) {
43
+ const status = error.code === "ENOENT" ? 404 : 500;
44
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
45
+ res.end(status === 404 ? "Not found" : "Internal server error");
46
+ }
47
+ });
48
+ await new Promise((resolve) => {
49
+ server.listen(port, host, resolve);
50
+ });
51
+ process.stdout.write(`Serving ${resolvedRoot} at http://${host}:${port}\n`);
52
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@emirotin/zerp",
3
+ "version": "0.1.0",
4
+ "description": "Zero-config HTML presentation framework for slide decks stored in a slides/ folder.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+ssh://git@github.com/emirotin/zerp.git"
8
+ },
9
+ "homepage": "https://github.com/emirotin/zerp",
10
+ "keywords": [
11
+ "cli",
12
+ "html",
13
+ "presentation",
14
+ "slides",
15
+ "typescript"
16
+ ],
17
+ "license": "MIT",
18
+ "bin": {
19
+ "zerp": "./dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "AGENTS.md",
25
+ "CLAUDE.md"
26
+ ],
27
+ "type": "module",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js"
37
+ }
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.6.0",
41
+ "husky": "^9.1.7",
42
+ "lint-staged": "^16.4.0",
43
+ "oxfmt": "^0.44.0",
44
+ "oxlint": "^1.59.0",
45
+ "typescript": "^5.9.3"
46
+ },
47
+ "lint-staged": {
48
+ "*.{js,mjs,cjs,ts}": [
49
+ "oxfmt --write",
50
+ "oxlint --fix --deny-warnings --node-plugin --import-plugin"
51
+ ]
52
+ },
53
+ "engines": {
54
+ "node": "24.14.1",
55
+ "pnpm": "10.33.0"
56
+ },
57
+ "volta": {
58
+ "node": "24.14.1",
59
+ "pnpm": "10.33.0"
60
+ },
61
+ "scripts": {
62
+ "build": "node ./scripts/build.mjs",
63
+ "check": "tsc -p tsconfig.json --noEmit",
64
+ "format": "oxfmt --write .",
65
+ "format:check": "oxfmt --check .",
66
+ "lint": "oxlint --deny-warnings --node-plugin --import-plugin src scripts .zed",
67
+ "lint:fix": "oxlint --fix --deny-warnings --node-plugin --import-plugin src scripts .zed"
68
+ }
69
+ }