@exor404/mdslides 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/LICENSE +21 -0
- package/README.md +184 -0
- package/app/astro.config.mjs +27 -0
- package/app/integrations/mdslides-assets.js +167 -0
- package/app/integrations/pdf-export.js +86 -0
- package/app/integrations/rehype-shiki.js +59 -0
- package/app/integrations/remark-highlight.js +45 -0
- package/app/package.json +6 -0
- package/app/public/favicon.svg +17 -0
- package/app/src/layouts/Deck.astro +281 -0
- package/app/src/lib/deck.js +272 -0
- package/app/src/pages/index.astro +14 -0
- package/app/src/pages/print.astro +11 -0
- package/app/src/themes/angular.css +407 -0
- package/bin/cli.js +274 -0
- package/bin/templates/slides.md +22 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eXor404
|
|
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,184 @@
|
|
|
1
|
+
# mdslides
|
|
2
|
+
|
|
3
|
+
> Markdown in, presentation out.
|
|
4
|
+
|
|
5
|
+
The slides sibling of [mdstack](https://github.com/eXor404/mdstack). Point it
|
|
6
|
+
at a folder — `mdslides` scaffolds a starter `slides.md`, then presents it in a
|
|
7
|
+
dev server or builds a self-contained `dist/` you can host anywhere static.
|
|
8
|
+
|
|
9
|
+
> **Status:** pre-1.0, provisional. CLI flags and markdown conventions may
|
|
10
|
+
> change before `0.x → 1.0`.
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
mdslides ./chemistry # scaffolds a project, then presents it
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Point `mdslides` at a folder and it scaffolds a project: it **asks for a
|
|
19
|
+
name**, writes `<name>.md` (e.g. `chemistry.md`) with three sample slides — a
|
|
20
|
+
title page, a list, and a help slide — plus a `mdslides.config.js`, then opens
|
|
21
|
+
the dev server. The name defaults to the folder, so pressing Enter at
|
|
22
|
+
`Name your presentation (chemistry):` gives you `chemistry/chemistry.md`. That
|
|
23
|
+
file *is* your deck — edit it and the slides live-reload.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mdslides ./chemistry # present (dev server, live-reload)
|
|
27
|
+
mdslides build ./chemistry # static build → ./chemistry/dist/
|
|
28
|
+
mdslides preview ./chemistry # serve the built dist/
|
|
29
|
+
mdslides export ./chemistry # → chemistry.pdf (one page per slide)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Already have a deck? Point straight at the file: `mdslides ./chemistry.md`.
|
|
33
|
+
|
|
34
|
+
Try the bundled example:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install
|
|
38
|
+
npm run dev # opens ./example
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## The deck model
|
|
42
|
+
|
|
43
|
+
**One markdown file is the whole deck.** Every `#` (H1) heading starts a new
|
|
44
|
+
slide — write plain markdown in between. A standalone `---` line is an
|
|
45
|
+
explicit break (for a slide with no heading, or to split one section).
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
48
|
+
# Presentation title
|
|
49
|
+
## A subtitle
|
|
50
|
+
**Your Name** · {{date}}
|
|
51
|
+
|
|
52
|
+
# Second slide
|
|
53
|
+
- a point
|
|
54
|
+
- another (bullets reveal one at a time)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Title slide
|
|
58
|
+
|
|
59
|
+
The **first slide is the title slide** automatically: the `#` renders large,
|
|
60
|
+
`##` is the subtitle, and the last line becomes a byline pinned to the bottom —
|
|
61
|
+
write your presenter name(s) in markdown there. The `{{date}}` token resolves to
|
|
62
|
+
today's date. Opt a slide out with `<!-- slide: plain -->`, or mark another
|
|
63
|
+
slide as a title with `<!-- slide: title -->`.
|
|
64
|
+
|
|
65
|
+
## Supported markdown (the lean subset)
|
|
66
|
+
|
|
67
|
+
Only what slides actually need:
|
|
68
|
+
|
|
69
|
+
- Headings, paragraphs, **bold**, *italic*, ~~strike~~, ==highlight==
|
|
70
|
+
- Lists (reveal one item at a time — see [Reveal & motion](#reveal--motion)), tables, blockquotes
|
|
71
|
+
- `inline code` and fenced code blocks (syntax-highlighted via [Shiki](https://shiki.style))
|
|
72
|
+
- Math — `$inline$` and `$$block$$` via KaTeX
|
|
73
|
+
- Images (relative paths resolve from the deck folder)
|
|
74
|
+
|
|
75
|
+
Fence a block with a language for syntax highlighting:
|
|
76
|
+
|
|
77
|
+
````markdown
|
|
78
|
+
```js
|
|
79
|
+
export default { theme: 'angular' };
|
|
80
|
+
```
|
|
81
|
+
````
|
|
82
|
+
|
|
83
|
+
## Presenting
|
|
84
|
+
|
|
85
|
+
| Key | Action |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `→` `space` `PageDn` | next slide / reveal next fragment |
|
|
88
|
+
| `←` `PageUp` | previous |
|
|
89
|
+
| `Home` / `End` | first / last slide |
|
|
90
|
+
| `O` | overview grid (click a slide to jump) |
|
|
91
|
+
| `F` | fullscreen |
|
|
92
|
+
| `.` | black screen |
|
|
93
|
+
| `?` | help |
|
|
94
|
+
|
|
95
|
+
The URL hash tracks the current slide (`#/3`) so deep links work.
|
|
96
|
+
|
|
97
|
+
### Reveal & motion
|
|
98
|
+
|
|
99
|
+
Slides build themselves as you talk:
|
|
100
|
+
|
|
101
|
+
- **Content fades in** when a slide opens — headings, paragraphs, images, code,
|
|
102
|
+
tables and quotes rise into place with a light stagger.
|
|
103
|
+
- **Lists reveal step by step.** Every list item is hidden when the slide opens
|
|
104
|
+
and appears one at a time on `→` / `space`; `←` steps back. Once all items
|
|
105
|
+
are shown, the next press advances to the following slide. This is automatic —
|
|
106
|
+
you don't need to mark anything.
|
|
107
|
+
- **Manual fragments.** Append `{.fragment}` to any other line (a paragraph,
|
|
108
|
+
a heading) to hold it back and reveal it on a later press, in document order
|
|
109
|
+
alongside the list items.
|
|
110
|
+
|
|
111
|
+
In the **overview grid** and **PDF export**, everything is shown at once. Motion
|
|
112
|
+
respects the OS "reduce motion" setting.
|
|
113
|
+
|
|
114
|
+
### Per-slide directives
|
|
115
|
+
|
|
116
|
+
A leading comment configures one slide:
|
|
117
|
+
|
|
118
|
+
```markdown
|
|
119
|
+
# A slide
|
|
120
|
+
<!-- slide: center bg=#1a0b2e -->
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- `center` — vertically + horizontally centered
|
|
124
|
+
- `bg=<color|url(...)>` — slide background
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
First run drops a `mdslides.config.js` next to your deck:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
export default {
|
|
132
|
+
brand: { text: 'mdslides' }, // bottom-left wordmark; '' to hide
|
|
133
|
+
theme: 'angular', // built-in: 'angular'
|
|
134
|
+
transition: 'fade', // 'fade' | 'slide' | 'none'
|
|
135
|
+
};
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## PDF export
|
|
139
|
+
|
|
140
|
+
`mdslides export` renders the `/print` route (one slide per page, all
|
|
141
|
+
fragments shown). It uses [Playwright](https://playwright.dev) if installed
|
|
142
|
+
(`npm i -D playwright && npx playwright install chromium`); otherwise open
|
|
143
|
+
`/print` in a browser and use **Save as PDF**.
|
|
144
|
+
|
|
145
|
+
## Releasing
|
|
146
|
+
|
|
147
|
+
Releases go to the **public npm registry** (npmjs.com), so anyone can
|
|
148
|
+
`npx @exor404/mdslides`. A tag-triggered GitHub Actions pipeline
|
|
149
|
+
([`.github/workflows/package-publish.yml`](.github/workflows/package-publish.yml))
|
|
150
|
+
publishes via **OIDC Trusted Publishing** — GitHub mints a one-time identity
|
|
151
|
+
token for the run, so **no npm token or secret is ever stored**.
|
|
152
|
+
|
|
153
|
+
**One-time setup** (needed once, because Trusted Publishing attaches to an
|
|
154
|
+
existing package):
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm login # browser sign-in to npmjs.com
|
|
158
|
+
npm publish --access public # publish the first version by hand
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Then on npmjs.com open the package → **Settings → Trusted Publisher → GitHub
|
|
162
|
+
Actions** and register:
|
|
163
|
+
|
|
164
|
+
- **Repository:** `eXor404/mdslides`
|
|
165
|
+
- **Workflow filename:** `package-publish.yml`
|
|
166
|
+
|
|
167
|
+
After that, every release is automatic:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm version patch # bumps package.json (0.1.0 → 0.1.1) and tags v0.1.1
|
|
171
|
+
git push origin main # push the version-bump commit
|
|
172
|
+
git push origin v0.1.1 # push the tag → the pipeline publishes, tokenlessly
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`npm version` creates the matching `vX.Y.Z` tag for you. The workflow refuses
|
|
176
|
+
to publish if the tag and `package.json` version disagree, so they can't drift.
|
|
177
|
+
|
|
178
|
+
## Requirements
|
|
179
|
+
|
|
180
|
+
Node 18+.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig } from 'astro/config';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import mdslidesAssets from './integrations/mdslides-assets.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const theme = process.env.MD_THEME || 'angular';
|
|
8
|
+
const themeFile = resolve(__dirname, 'src', 'themes', `${theme}.css`);
|
|
9
|
+
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
// Markdown is rendered by src/lib/deck.js (a dedicated unified pipeline),
|
|
12
|
+
// not Astro's content layer — so there's no markdown config here.
|
|
13
|
+
integrations: [mdslidesAssets()],
|
|
14
|
+
vite: {
|
|
15
|
+
resolve: {
|
|
16
|
+
alias: {
|
|
17
|
+
'~theme': themeFile,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
server: {
|
|
21
|
+
// mdslides is a local CLI; deps live above APP_ROOT (or hoisted under
|
|
22
|
+
// npx). Disable strict fs serving so KaTeX fonts and source-folder
|
|
23
|
+
// assets resolve regardless of where they're installed.
|
|
24
|
+
fs: { strict: false },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { promises as fs, mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { resolve, relative, extname, join, dirname, basename, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { findChrome, printUrlToPdf } from './pdf-export.js';
|
|
6
|
+
|
|
7
|
+
const MIME = {
|
|
8
|
+
'.png': 'image/png',
|
|
9
|
+
'.jpg': 'image/jpeg',
|
|
10
|
+
'.jpeg': 'image/jpeg',
|
|
11
|
+
'.gif': 'image/gif',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
'.avif': 'image/avif',
|
|
15
|
+
'.ico': 'image/x-icon',
|
|
16
|
+
'.mp4': 'video/mp4',
|
|
17
|
+
'.webm': 'video/webm',
|
|
18
|
+
'.mp3': 'audio/mpeg',
|
|
19
|
+
'.woff': 'font/woff',
|
|
20
|
+
'.woff2': 'font/woff2',
|
|
21
|
+
'.ttf': 'font/ttf',
|
|
22
|
+
'.otf': 'font/otf',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const EXCLUDED_TOP = new Set(['dist', 'node_modules', '.git', '.astro', '.vscode', '.idea']);
|
|
26
|
+
const EXCLUDED_FILES = new Set(['mdslides.config.js']);
|
|
27
|
+
|
|
28
|
+
function isExcludedRel(rel) {
|
|
29
|
+
if (!rel || rel === '.' || rel === '..') return true;
|
|
30
|
+
if (EXCLUDED_FILES.has(rel)) return true;
|
|
31
|
+
const parts = rel.split(/[\\/]/);
|
|
32
|
+
if (parts[0].startsWith('.')) return true;
|
|
33
|
+
if (EXCLUDED_TOP.has(parts[0])) return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Copy every non-.md file from the source folder so a built deck is
|
|
38
|
+
// self-contained (images, fonts, video…).
|
|
39
|
+
async function* walkAssets(root, current = root) {
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const full = join(current, entry.name);
|
|
48
|
+
const rel = relative(root, full);
|
|
49
|
+
if (isExcludedRel(rel)) continue;
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
yield* walkAssets(root, full);
|
|
52
|
+
} else if (entry.isFile() && extname(entry.name).toLowerCase() !== '.md') {
|
|
53
|
+
yield { full, rel };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default function mdslidesAssets() {
|
|
59
|
+
const source = process.env.MD_SOURCE;
|
|
60
|
+
const deckFile = process.env.MD_FILE;
|
|
61
|
+
if (!source) {
|
|
62
|
+
throw new Error('mdslides: MD_SOURCE env var not set — run via the mdslides CLI.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
name: 'mdslides:assets',
|
|
67
|
+
hooks: {
|
|
68
|
+
'astro:server:setup': ({ server }) => {
|
|
69
|
+
// PDF export endpoint: prints the live /print route to PDF with
|
|
70
|
+
// headless Chrome and streams it back as a download. This is the deck
|
|
71
|
+
// PDF button's primary path — browser-independent and pixel-accurate
|
|
72
|
+
// (Safari/Chrome ⌘P can't reliably size or color slide pages).
|
|
73
|
+
server.middlewares.use(async (req, res, next) => {
|
|
74
|
+
const path = (req.url || '').split('?')[0];
|
|
75
|
+
if (path !== '/__export.pdf') return next();
|
|
76
|
+
const chrome = findChrome();
|
|
77
|
+
if (!chrome) {
|
|
78
|
+
res.statusCode = 501;
|
|
79
|
+
res.end('No headless Chrome found. Install Google Chrome or set CHROME_PATH.');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const host = req.headers.host || 'localhost';
|
|
83
|
+
const name = deckFile ? basename(deckFile).replace(/\.md$/i, '') : 'slides';
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), 'mdslides-pdf-'));
|
|
85
|
+
const pdfPath = join(dir, `${name}.pdf`);
|
|
86
|
+
try {
|
|
87
|
+
await printUrlToPdf(chrome, `http://${host}/print/`, pdfPath);
|
|
88
|
+
const data = await fs.readFile(pdfPath);
|
|
89
|
+
res.setHeader('Content-Type', 'application/pdf');
|
|
90
|
+
res.setHeader('Content-Disposition', `attachment; filename="${name}.pdf"`);
|
|
91
|
+
res.setHeader('Content-Length', String(data.length));
|
|
92
|
+
res.end(data);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
res.statusCode = 500;
|
|
95
|
+
res.end(`PDF export failed: ${err.message}`);
|
|
96
|
+
} finally {
|
|
97
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Live-reload when the deck markdown changes (it lives outside Astro's
|
|
102
|
+
// own watched tree, so add it explicitly).
|
|
103
|
+
if (deckFile) {
|
|
104
|
+
server.watcher.add(deckFile);
|
|
105
|
+
server.watcher.on('change', (changed) => {
|
|
106
|
+
if (resolve(changed) === resolve(deckFile)) {
|
|
107
|
+
server.ws.send({ type: 'full-reload' });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Serve source-folder assets in dev.
|
|
113
|
+
server.middlewares.use(async (req, res, next) => {
|
|
114
|
+
if (!req.url || (req.method && req.method !== 'GET' && req.method !== 'HEAD')) {
|
|
115
|
+
return next();
|
|
116
|
+
}
|
|
117
|
+
let pathname;
|
|
118
|
+
try {
|
|
119
|
+
pathname = decodeURIComponent(req.url.split('?')[0].split('#')[0]);
|
|
120
|
+
} catch {
|
|
121
|
+
return next();
|
|
122
|
+
}
|
|
123
|
+
if (!pathname || pathname === '/' || pathname.endsWith('/')) return next();
|
|
124
|
+
const ext = extname(pathname).toLowerCase();
|
|
125
|
+
if (!ext || ext === '.md') return next();
|
|
126
|
+
if (pathname.startsWith('/@') || pathname.startsWith('/_astro/') || pathname.startsWith('/node_modules/')) {
|
|
127
|
+
return next();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rel = pathname.replace(/^\/+/, '');
|
|
131
|
+
if (isExcludedRel(rel)) return next();
|
|
132
|
+
|
|
133
|
+
const filePath = resolve(source, rel);
|
|
134
|
+
const sourceWithSep = source.endsWith(sep) ? source : source + sep;
|
|
135
|
+
if (filePath !== source && !filePath.startsWith(sourceWithSep)) return next();
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const stat = await fs.stat(filePath);
|
|
139
|
+
if (!stat.isFile()) return next();
|
|
140
|
+
res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
|
|
141
|
+
res.setHeader('Content-Length', String(stat.size));
|
|
142
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
143
|
+
if (req.method === 'HEAD') {
|
|
144
|
+
res.end();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
res.end(await fs.readFile(filePath));
|
|
148
|
+
} catch {
|
|
149
|
+
return next();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
'astro:build:done': async ({ dir, logger }) => {
|
|
154
|
+
const outDir = fileURLToPath(dir);
|
|
155
|
+
const log = logger?.info?.bind(logger) ?? console.log;
|
|
156
|
+
let count = 0;
|
|
157
|
+
for await (const { full, rel } of walkAssets(source)) {
|
|
158
|
+
const dest = join(outDir, rel);
|
|
159
|
+
await fs.mkdir(dirname(dest), { recursive: true });
|
|
160
|
+
await fs.copyFile(full, dest);
|
|
161
|
+
count++;
|
|
162
|
+
}
|
|
163
|
+
if (count > 0) log(`copied ${count} asset${count === 1 ? '' : 's'} from source folder`);
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Shared headless-Chrome PDF helper, used by both the `mdslides export` CLI
|
|
2
|
+
// command and the dev server's /__export.pdf endpoint.
|
|
3
|
+
import { spawn, execSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, statSync, mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
function whichSync(bin) {
|
|
9
|
+
try {
|
|
10
|
+
return execSync(`command -v ${bin}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function findChrome() {
|
|
17
|
+
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
|
|
18
|
+
const candidates = [
|
|
19
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
20
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
21
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
22
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
23
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
24
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
25
|
+
];
|
|
26
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
27
|
+
for (const bin of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) {
|
|
28
|
+
const found = whichSync(bin);
|
|
29
|
+
if (found) return found;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Print a URL to `pdfPath` with headless Chrome. Recent Chrome
|
|
35
|
+
// (`--headless=new`) does the print but doesn't reliably exit, so we wait for
|
|
36
|
+
// the PDF's size to settle, then kill the process — robust across versions.
|
|
37
|
+
export function printUrlToPdf(chrome, url, pdfPath) {
|
|
38
|
+
return new Promise((res, rej) => {
|
|
39
|
+
try { if (existsSync(pdfPath)) rmSync(pdfPath); } catch {}
|
|
40
|
+
const profile = mkdtempSync(join(tmpdir(), 'mdslides-'));
|
|
41
|
+
const args = [
|
|
42
|
+
'--headless=new',
|
|
43
|
+
'--disable-gpu',
|
|
44
|
+
'--no-sandbox',
|
|
45
|
+
'--hide-scrollbars',
|
|
46
|
+
'--no-pdf-header-footer',
|
|
47
|
+
'--run-all-compositor-stages-before-draw',
|
|
48
|
+
'--virtual-time-budget=5000',
|
|
49
|
+
`--user-data-dir=${profile}`,
|
|
50
|
+
`--print-to-pdf=${pdfPath}`,
|
|
51
|
+
url,
|
|
52
|
+
];
|
|
53
|
+
const ps = spawn(chrome, args, { stdio: 'ignore' });
|
|
54
|
+
|
|
55
|
+
let done = false;
|
|
56
|
+
let lastSize = -1;
|
|
57
|
+
let stable = 0;
|
|
58
|
+
const finish = (err) => {
|
|
59
|
+
if (done) return;
|
|
60
|
+
done = true;
|
|
61
|
+
clearInterval(poll);
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
try { ps.kill('SIGKILL'); } catch {}
|
|
64
|
+
try { rmSync(profile, { recursive: true, force: true }); } catch {}
|
|
65
|
+
err ? rej(err) : res(pdfPath);
|
|
66
|
+
};
|
|
67
|
+
const poll = setInterval(() => {
|
|
68
|
+
if (!existsSync(pdfPath)) return;
|
|
69
|
+
let size = 0;
|
|
70
|
+
try { size = statSync(pdfPath).size; } catch { return; }
|
|
71
|
+
if (size > 0 && size === lastSize) {
|
|
72
|
+
if (++stable >= 3) finish();
|
|
73
|
+
} else {
|
|
74
|
+
stable = 0;
|
|
75
|
+
lastSize = size;
|
|
76
|
+
}
|
|
77
|
+
}, 300);
|
|
78
|
+
const timer = setTimeout(() => finish(new Error('Chrome timed out after 45s')), 45000);
|
|
79
|
+
|
|
80
|
+
ps.on('error', finish);
|
|
81
|
+
ps.on('exit', (code) => {
|
|
82
|
+
if (existsSync(pdfPath)) finish();
|
|
83
|
+
else finish(new Error(`Chrome exited with code ${code} without producing a PDF`));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Syntax-highlight fenced code blocks with Shiki.
|
|
2
|
+
//
|
|
3
|
+
// We keep the deck's own `<pre><code>` wrapper (so the theme's border, radius,
|
|
4
|
+
// padding and dark background still apply) and only swap the code's children
|
|
5
|
+
// for Shiki's colored token spans. Spans carry inline `color` styles, so no
|
|
6
|
+
// extra theme CSS is needed. Code blocks with no language — or an unknown one —
|
|
7
|
+
// are left untouched.
|
|
8
|
+
import { codeToHast } from 'shiki';
|
|
9
|
+
|
|
10
|
+
const THEME = 'github-dark';
|
|
11
|
+
|
|
12
|
+
function rawText(node) {
|
|
13
|
+
if (node.type === 'text') return node.value;
|
|
14
|
+
if (Array.isArray(node.children)) return node.children.map(rawText).join('');
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function langOf(codeEl) {
|
|
19
|
+
const cls = codeEl.properties?.className;
|
|
20
|
+
const list = Array.isArray(cls) ? cls : cls ? [cls] : [];
|
|
21
|
+
const hit = list.map(String).find((c) => c.startsWith('language-'));
|
|
22
|
+
return hit ? hit.slice('language-'.length) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function rehypeShiki() {
|
|
26
|
+
// Collect <pre><code> blocks first, then highlight them (async).
|
|
27
|
+
return async (tree) => {
|
|
28
|
+
const blocks = [];
|
|
29
|
+
const visit = (node) => {
|
|
30
|
+
if (
|
|
31
|
+
node.type === 'element' &&
|
|
32
|
+
node.tagName === 'pre' &&
|
|
33
|
+
node.children?.[0]?.type === 'element' &&
|
|
34
|
+
node.children[0].tagName === 'code'
|
|
35
|
+
) {
|
|
36
|
+
blocks.push(node.children[0]);
|
|
37
|
+
return; // don't descend into code
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(node.children)) node.children.forEach(visit);
|
|
40
|
+
};
|
|
41
|
+
visit(tree);
|
|
42
|
+
|
|
43
|
+
await Promise.all(
|
|
44
|
+
blocks.map(async (code) => {
|
|
45
|
+
const lang = langOf(code);
|
|
46
|
+
if (!lang) return;
|
|
47
|
+
const source = rawText(code).replace(/\n$/, '');
|
|
48
|
+
try {
|
|
49
|
+
const hast = await codeToHast(source, { lang, theme: THEME });
|
|
50
|
+
const pre = hast.children.find((c) => c.tagName === 'pre');
|
|
51
|
+
const inner = pre?.children.find((c) => c.tagName === 'code');
|
|
52
|
+
if (inner) code.children = inner.children;
|
|
53
|
+
} catch {
|
|
54
|
+
// Unknown language → leave the code block as plain text.
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// ==highlight== → <mark>highlight</mark>
|
|
2
|
+
const HIGHLIGHT_RE = /==([^=\n]+?)==/g;
|
|
3
|
+
|
|
4
|
+
function transformChildren(parent) {
|
|
5
|
+
if (!Array.isArray(parent.children)) return;
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const child of parent.children) {
|
|
8
|
+
if (Array.isArray(child.children)) transformChildren(child);
|
|
9
|
+
|
|
10
|
+
if (child.type !== 'text' || typeof child.value !== 'string' || !child.value.includes('==')) {
|
|
11
|
+
out.push(child);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const value = child.value;
|
|
16
|
+
let lastIdx = 0;
|
|
17
|
+
let matched = false;
|
|
18
|
+
HIGHLIGHT_RE.lastIndex = 0;
|
|
19
|
+
let m;
|
|
20
|
+
while ((m = HIGHLIGHT_RE.exec(value)) !== null) {
|
|
21
|
+
matched = true;
|
|
22
|
+
if (m.index > lastIdx) {
|
|
23
|
+
out.push({ type: 'text', value: value.slice(lastIdx, m.index) });
|
|
24
|
+
}
|
|
25
|
+
// Custom mdast node → <mark> via data.hName (no raw HTML, so it
|
|
26
|
+
// survives remark-rehype without allowDangerousHtml).
|
|
27
|
+
out.push({
|
|
28
|
+
type: 'highlight',
|
|
29
|
+
data: { hName: 'mark' },
|
|
30
|
+
children: [{ type: 'text', value: m[1] }],
|
|
31
|
+
});
|
|
32
|
+
lastIdx = m.index + m[0].length;
|
|
33
|
+
}
|
|
34
|
+
if (!matched) {
|
|
35
|
+
out.push(child);
|
|
36
|
+
} else if (lastIdx < value.length) {
|
|
37
|
+
out.push({ type: 'text', value: value.slice(lastIdx) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
parent.children = out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function remarkHighlight() {
|
|
44
|
+
return (tree) => transformChildren(tree);
|
|
45
|
+
}
|
package/app/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#ff0080"/>
|
|
5
|
+
<stop offset="50%" stop-color="#ff4500"/>
|
|
6
|
+
<stop offset="100%" stop-color="#ffa500"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
<style>
|
|
9
|
+
.frame { fill: #0d0d0d; }
|
|
10
|
+
@media (prefers-color-scheme: light) { .frame { fill: #ffffff; } }
|
|
11
|
+
</style>
|
|
12
|
+
</defs>
|
|
13
|
+
<rect x="0" y="0" width="28" height="28" rx="7" fill="url(#g)"/>
|
|
14
|
+
<rect x="4" y="9" width="20" height="13" rx="2" class="frame"/>
|
|
15
|
+
<rect x="7" y="12" width="14" height="2.4" rx="1.2" fill="url(#g)"/>
|
|
16
|
+
<rect x="7" y="16.5" width="9" height="2.4" rx="1.2" fill="url(#g)"/>
|
|
17
|
+
</svg>
|