@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 +28 -0
- package/CLAUDE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/assets/default-runtime.js +93 -0
- package/dist/assets/default-styles.css +294 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +48 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/presentation.d.ts +14 -0
- package/dist/presentation.js +114 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +52 -0
- package/package.json +69 -0
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
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
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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("&", "&")
|
|
8
|
+
.replaceAll("<", "<")
|
|
9
|
+
.replaceAll(">", ">")
|
|
10
|
+
.replaceAll('"', """);
|
|
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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|