pdf-smith 0.2.0 → 0.4.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.
@@ -1,3 +1,9 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { colors, fonts, radii, SIDEBAR_WIDTH } from './design-tokens';
3
+ import { ChevronLeftIcon, DownloadIcon, GridViewIcon } from './icons';
4
+
5
+ type ExportState = 'idle' | 'exporting' | 'error';
6
+
1
7
  interface NavigationProps {
2
8
  pages: Record<string, React.ComponentType>;
3
9
  activePage: string | null;
@@ -5,116 +11,343 @@ interface NavigationProps {
5
11
  documentSlug: string;
6
12
  }
7
13
 
8
- const navStyle: React.CSSProperties = {
9
- position: 'fixed',
10
- top: 0,
11
- left: 0,
12
- width: '240px',
13
- height: '100vh',
14
- background: '#1a1a2e',
15
- color: '#e0e0e0',
16
- padding: '16px',
17
- overflowY: 'auto',
18
- fontFamily: 'system-ui, -apple-system, sans-serif',
19
- fontSize: '14px',
20
- zIndex: 1000,
21
- boxSizing: 'border-box',
22
- };
23
-
24
- const titleStyle: React.CSSProperties = {
25
- fontSize: '16px',
26
- fontWeight: 700,
27
- marginBottom: '16px',
28
- color: '#ffffff',
29
- letterSpacing: '0.5px',
30
- };
31
-
32
- const backLinkStyle: React.CSSProperties = {
33
- display: 'block',
34
- fontSize: '13px',
35
- color: '#888',
36
- textDecoration: 'none',
37
- marginBottom: '12px',
38
- transition: 'color 0.15s',
39
- };
40
-
41
- const buttonBaseStyle: React.CSSProperties = {
42
- display: 'block',
43
- width: '100%',
44
- padding: '8px 12px',
45
- border: 'none',
46
- borderRadius: '6px',
47
- cursor: 'pointer',
48
- textAlign: 'left',
49
- fontSize: '13px',
50
- marginBottom: '4px',
51
- transition: 'background 0.15s',
52
- fontFamily: 'inherit',
53
- };
54
-
55
- function getButtonStyle(isActive: boolean): React.CSSProperties {
56
- return {
57
- ...buttonBaseStyle,
58
- background: isActive ? '#16213e' : 'transparent',
59
- color: isActive ? '#ffffff' : '#b0b0b0',
60
- fontWeight: isActive ? 600 : 400,
61
- };
14
+ function formatSlug(slug: string): string {
15
+ return slug
16
+ .split('-')
17
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
18
+ .join(' ');
62
19
  }
63
20
 
64
- const separatorStyle: React.CSSProperties = {
65
- border: 'none',
66
- borderTop: '1px solid #2a2a4a',
67
- margin: '12px 0',
68
- };
69
-
70
21
  export function Navigation({ pages, activePage, onSelectPage, documentSlug }: NavigationProps) {
71
22
  const pageNames = Object.keys(pages);
23
+ const [exportState, setExportState] = useState<ExportState>('idle');
24
+
25
+ useEffect(() => {
26
+ if (exportState !== 'error') return;
27
+ const timer = setTimeout(() => setExportState('idle'), 3000);
28
+ return () => clearTimeout(timer);
29
+ }, [exportState]);
30
+
31
+ async function handleExport() {
32
+ setExportState('exporting');
33
+ try {
34
+ const res = await fetch(`/api/export/${documentSlug}`, { method: 'POST' });
35
+ if (!res.ok) {
36
+ const body = await res.json().catch(() => ({ error: 'Export failed' }));
37
+ throw new Error(body.error ?? 'Export failed');
38
+ }
39
+ const contentType = res.headers.get('Content-Type') ?? '';
40
+ if (!contentType.includes('application/pdf')) {
41
+ throw new Error('Server returned unexpected response');
42
+ }
43
+ const blob = await res.blob();
44
+ const disposition = res.headers.get('Content-Disposition') ?? '';
45
+ const filenameMatch = disposition.match(/filename="(.+)"/);
46
+ const filename = filenameMatch?.[1] ?? `${documentSlug}.pdf`;
47
+ const url = URL.createObjectURL(blob);
48
+ const a = document.createElement('a');
49
+ a.href = url;
50
+ a.download = filename;
51
+ document.body.appendChild(a);
52
+ a.click();
53
+ a.remove();
54
+ URL.revokeObjectURL(url);
55
+ setExportState('idle');
56
+ } catch {
57
+ setExportState('error');
58
+ }
59
+ }
72
60
 
73
61
  return (
74
- <nav data-pdf-smith-nav="" style={navStyle}>
75
- <a
76
- href="/"
77
- style={backLinkStyle}
78
- onMouseEnter={(e) => {
79
- e.currentTarget.style.color = '#e0e0e0';
80
- }}
81
- onMouseLeave={(e) => {
82
- e.currentTarget.style.color = '#888';
62
+ <nav
63
+ data-pdf-smith-nav=""
64
+ style={{
65
+ position: 'fixed',
66
+ left: 0,
67
+ top: 0,
68
+ bottom: 0,
69
+ width: `${SIDEBAR_WIDTH}px`,
70
+ background: colors.stone900,
71
+ color: colors.stone300,
72
+ display: 'flex',
73
+ flexDirection: 'column',
74
+ zIndex: 100,
75
+ borderRight: `1px solid ${colors.stone800}`,
76
+ fontFamily: fonts.body,
77
+ boxSizing: 'border-box',
78
+ }}
79
+ >
80
+ {/* Header */}
81
+ <div
82
+ style={{
83
+ padding: '16px 20px',
84
+ borderBottom: `1px solid ${colors.stone800}`,
83
85
  }}
84
86
  >
85
- &larr; All Documents
86
- </a>
87
- <div style={titleStyle}>{documentSlug}</div>
88
- <button
89
- type="button"
90
- style={getButtonStyle(activePage === null)}
91
- onClick={() => onSelectPage(null)}
92
- onMouseEnter={(e) => {
93
- if (activePage !== null) e.currentTarget.style.background = '#16213e40';
94
- }}
95
- onMouseLeave={(e) => {
96
- if (activePage !== null) e.currentTarget.style.background = 'transparent';
87
+ <a
88
+ href="/"
89
+ style={{
90
+ display: 'flex',
91
+ alignItems: 'center',
92
+ gap: '6px',
93
+ fontSize: '13px',
94
+ color: colors.stone400,
95
+ textDecoration: 'none',
96
+ transition: 'color 0.15s',
97
+ marginBottom: '12px',
98
+ }}
99
+ onMouseEnter={(e) => {
100
+ e.currentTarget.style.color = colors.stone200;
101
+ }}
102
+ onMouseLeave={(e) => {
103
+ e.currentTarget.style.color = colors.stone400;
104
+ }}
105
+ >
106
+ <ChevronLeftIcon size={14} />
107
+ All Documents
108
+ </a>
109
+ <div
110
+ style={{
111
+ fontFamily: fonts.heading,
112
+ fontSize: '16px',
113
+ fontWeight: 600,
114
+ color: colors.stone100,
115
+ }}
116
+ >
117
+ {formatSlug(documentSlug)}
118
+ </div>
119
+ <div
120
+ style={{
121
+ fontFamily: fonts.mono,
122
+ fontSize: '11px',
123
+ color: colors.stone500,
124
+ marginTop: '2px',
125
+ }}
126
+ >
127
+ pdfs/{documentSlug}
128
+ </div>
129
+ </div>
130
+
131
+ {/* Page Navigation */}
132
+ <div style={{ flex: 1, overflowY: 'auto', padding: '12px' }}>
133
+ <div
134
+ style={{
135
+ fontSize: '11px',
136
+ fontWeight: 600,
137
+ textTransform: 'uppercase',
138
+ letterSpacing: '0.06em',
139
+ color: colors.stone500,
140
+ padding: '8px 8px 6px',
141
+ }}
142
+ >
143
+ Pages
144
+ </div>
145
+
146
+ {/* All Pages button */}
147
+ <button
148
+ type="button"
149
+ onClick={() => onSelectPage(null)}
150
+ style={{
151
+ display: 'flex',
152
+ alignItems: 'center',
153
+ gap: '10px',
154
+ padding: '8px 10px',
155
+ borderRadius: radii.md,
156
+ color: activePage === null ? colors.ember : colors.stone300,
157
+ fontSize: '13px',
158
+ fontWeight: 500,
159
+ fontFamily: 'inherit',
160
+ transition: 'all 0.12s',
161
+ cursor: 'pointer',
162
+ marginBottom: '8px',
163
+ border: `1px solid ${activePage === null ? colors.forge : colors.stone700}`,
164
+ background: activePage === null ? 'rgba(194, 65, 12, 0.1)' : 'transparent',
165
+ width: '100%',
166
+ textAlign: 'left',
167
+ }}
168
+ onMouseEnter={(e) => {
169
+ if (activePage !== null) {
170
+ e.currentTarget.style.background = 'rgba(250, 249, 247, 0.05)';
171
+ e.currentTarget.style.borderColor = colors.stone600;
172
+ }
173
+ }}
174
+ onMouseLeave={(e) => {
175
+ if (activePage !== null) {
176
+ e.currentTarget.style.background = 'transparent';
177
+ e.currentTarget.style.borderColor = colors.stone700;
178
+ }
179
+ }}
180
+ >
181
+ <GridViewIcon size={14} />
182
+ All Pages
183
+ </button>
184
+
185
+ {/* Individual page links */}
186
+ {pageNames.map((name, idx) => {
187
+ const isActive = activePage === name;
188
+ return (
189
+ <button
190
+ type="button"
191
+ key={name}
192
+ onClick={() => onSelectPage(name)}
193
+ style={{
194
+ display: 'flex',
195
+ alignItems: 'center',
196
+ gap: '10px',
197
+ padding: '8px 10px',
198
+ borderRadius: radii.md,
199
+ color: isActive ? colors.ember : colors.stone400,
200
+ fontSize: '13px',
201
+ fontFamily: 'inherit',
202
+ transition: 'all 0.12s',
203
+ cursor: 'pointer',
204
+ marginBottom: '2px',
205
+ border: 'none',
206
+ background: isActive ? 'rgba(194, 65, 12, 0.12)' : 'transparent',
207
+ width: '100%',
208
+ textAlign: 'left',
209
+ }}
210
+ onMouseEnter={(e) => {
211
+ if (!isActive) {
212
+ e.currentTarget.style.background = 'rgba(250, 249, 247, 0.05)';
213
+ e.currentTarget.style.color = colors.stone200;
214
+ }
215
+ }}
216
+ onMouseLeave={(e) => {
217
+ if (!isActive) {
218
+ e.currentTarget.style.background = 'transparent';
219
+ e.currentTarget.style.color = colors.stone400;
220
+ }
221
+ }}
222
+ >
223
+ <span
224
+ style={{
225
+ width: '22px',
226
+ height: '22px',
227
+ display: 'flex',
228
+ alignItems: 'center',
229
+ justifyContent: 'center',
230
+ fontFamily: fonts.mono,
231
+ fontSize: '11px',
232
+ fontWeight: 600,
233
+ background: isActive ? colors.forge : colors.stone800,
234
+ color: isActive ? 'white' : 'inherit',
235
+ borderRadius: '5px',
236
+ flexShrink: 0,
237
+ }}
238
+ >
239
+ {idx + 1}
240
+ </span>
241
+ <span
242
+ style={{
243
+ overflow: 'hidden',
244
+ textOverflow: 'ellipsis',
245
+ whiteSpace: 'nowrap',
246
+ }}
247
+ >
248
+ {name}
249
+ </span>
250
+ </button>
251
+ );
252
+ })}
253
+ </div>
254
+
255
+ {/* Footer */}
256
+ <div
257
+ style={{
258
+ padding: '12px 16px',
259
+ borderTop: `1px solid ${colors.stone800}`,
260
+ display: 'flex',
261
+ flexDirection: 'column',
262
+ gap: '8px',
97
263
  }}
98
264
  >
99
- All Pages
100
- </button>
101
- <hr style={separatorStyle} />
102
- {pageNames.map((name) => (
103
265
  <button
104
266
  type="button"
105
- key={name}
106
- style={getButtonStyle(activePage === name)}
107
- onClick={() => onSelectPage(name)}
267
+ disabled={exportState === 'exporting'}
268
+ onClick={handleExport}
269
+ style={{
270
+ display: 'flex',
271
+ alignItems: 'center',
272
+ justifyContent: 'center',
273
+ gap: '6px',
274
+ padding: '8px 16px',
275
+ background:
276
+ exportState === 'error'
277
+ ? '#DC2626'
278
+ : exportState === 'exporting'
279
+ ? colors.stone600
280
+ : colors.forge,
281
+ color: 'white',
282
+ border: 'none',
283
+ borderRadius: radii.md,
284
+ fontFamily: fonts.body,
285
+ fontSize: '13px',
286
+ fontWeight: 500,
287
+ cursor: exportState === 'exporting' ? 'not-allowed' : 'pointer',
288
+ transition: 'background 0.15s',
289
+ width: '100%',
290
+ opacity: exportState === 'exporting' ? 0.8 : 1,
291
+ }}
108
292
  onMouseEnter={(e) => {
109
- if (activePage !== name) e.currentTarget.style.background = '#16213e40';
293
+ if (exportState === 'idle') e.currentTarget.style.background = colors.deepForge;
110
294
  }}
111
295
  onMouseLeave={(e) => {
112
- if (activePage !== name) e.currentTarget.style.background = 'transparent';
296
+ if (exportState === 'idle') e.currentTarget.style.background = colors.forge;
113
297
  }}
114
298
  >
115
- {name}
299
+ {exportState === 'exporting' ? (
300
+ <>
301
+ <style>{`@keyframes pdf-smith-spin { to { transform: rotate(360deg) } }`}</style>
302
+ <span
303
+ style={{
304
+ width: '14px',
305
+ height: '14px',
306
+ border: '2px solid rgba(255,255,255,0.3)',
307
+ borderTopColor: 'white',
308
+ borderRadius: '50%',
309
+ animation: 'pdf-smith-spin 0.6s linear infinite',
310
+ flexShrink: 0,
311
+ }}
312
+ />
313
+ Exporting...
314
+ </>
315
+ ) : exportState === 'error' ? (
316
+ 'Export Failed'
317
+ ) : (
318
+ <>
319
+ <DownloadIcon size={14} />
320
+ Export PDF
321
+ </>
322
+ )}
116
323
  </button>
117
- ))}
324
+ <div
325
+ style={{
326
+ display: 'flex',
327
+ alignItems: 'center',
328
+ justifyContent: 'space-between',
329
+ fontSize: '11px',
330
+ color: colors.stone500,
331
+ fontFamily: fonts.mono,
332
+ }}
333
+ >
334
+ <span>
335
+ {pageNames.length} page{pageNames.length !== 1 ? 's' : ''}
336
+ </span>
337
+ <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
338
+ <span
339
+ style={{
340
+ width: '6px',
341
+ height: '6px',
342
+ borderRadius: '50%',
343
+ background: colors.success,
344
+ display: 'inline-block',
345
+ }}
346
+ />
347
+ HMR active
348
+ </span>
349
+ </div>
350
+ </div>
118
351
  </nav>
119
352
  );
120
353
  }
@@ -1,4 +1,8 @@
1
+ import fs from 'node:fs';
2
+ import type { AddressInfo } from 'node:net';
3
+ import path from 'node:path';
1
4
  import type { Plugin } from 'vite';
5
+ import type { DocumentConfig } from '../config';
2
6
 
3
7
  const VIRTUAL_DOCUMENTS_ID = 'virtual:pdf-smith-documents';
4
8
  const RESOLVED_VIRTUAL_DOCUMENTS_ID = `\0${VIRTUAL_DOCUMENTS_ID}`;
@@ -7,11 +11,110 @@ interface PreviewPluginOptions {
7
11
  pkgSrcDir: string;
8
12
  }
9
13
 
14
+ async function handleExportRequest(
15
+ server: import('vite').ViteDevServer,
16
+ slug: string,
17
+ res: import('node:http').ServerResponse,
18
+ ) {
19
+ const root = server.config.root;
20
+ const pagesDir = path.join(root, 'pdfs', slug, 'pages');
21
+
22
+ if (!fs.existsSync(pagesDir)) {
23
+ res.statusCode = 404;
24
+ res.setHeader('Content-Type', 'application/json');
25
+ res.end(JSON.stringify({ error: `Document "${slug}" not found` }));
26
+ return;
27
+ }
28
+
29
+ let config: DocumentConfig | undefined;
30
+ const configPath = path.join(root, 'pdfs', slug, 'config.ts');
31
+ if (fs.existsSync(configPath)) {
32
+ try {
33
+ const configModule = await server.ssrLoadModule(configPath);
34
+ config = configModule.default;
35
+ } catch {
36
+ // Config loading failed, proceed without it
37
+ }
38
+ }
39
+
40
+ let playwright: typeof import('playwright');
41
+ try {
42
+ playwright = await import('playwright');
43
+ } catch {
44
+ res.statusCode = 500;
45
+ res.setHeader('Content-Type', 'application/json');
46
+ res.end(
47
+ JSON.stringify({
48
+ error:
49
+ 'Playwright is required for PDF export. Install it with: npm install -D playwright && npx playwright install chromium',
50
+ }),
51
+ );
52
+ return;
53
+ }
54
+
55
+ let browser: import('playwright').Browser | undefined;
56
+ try {
57
+ const address = server.httpServer?.address() as AddressInfo | null;
58
+ const port = address?.port;
59
+ if (!port) throw new Error('Could not determine server port');
60
+
61
+ browser = await playwright.chromium.launch({ headless: true });
62
+ const page = await browser.newPage();
63
+ await page.goto(`http://localhost:${port}/${slug}`, { waitUntil: 'networkidle' });
64
+ await page.waitForSelector('[data-pdf-smith-page]', { timeout: 30_000 });
65
+
66
+ const margin = { top: '0', bottom: '0', left: '0', right: '0' };
67
+ const pdfOptions: Parameters<typeof page.pdf>[0] = {
68
+ preferCSSPageSize: true,
69
+ printBackground: true,
70
+ margin,
71
+ };
72
+
73
+ if (config?.pageNumbers?.enabled) {
74
+ pdfOptions.displayHeaderFooter = true;
75
+ pdfOptions.headerTemplate = '<span></span>';
76
+ pdfOptions.footerTemplate =
77
+ config.pageNumbers.template ??
78
+ '<div style="font-size:10px;text-align:center;width:100%;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>';
79
+ margin.bottom = '40px';
80
+ }
81
+
82
+ const buffer = await page.pdf(pdfOptions);
83
+ await page.close();
84
+
85
+ const filename = config?.output ?? `${slug}.pdf`;
86
+ res.statusCode = 200;
87
+ res.setHeader('Content-Type', 'application/pdf');
88
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
89
+ res.end(buffer);
90
+ } catch (err) {
91
+ if (!res.headersSent) {
92
+ res.statusCode = 500;
93
+ res.setHeader('Content-Type', 'application/json');
94
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'PDF export failed' }));
95
+ }
96
+ } finally {
97
+ await browser?.close().catch(() => {});
98
+ }
99
+ }
100
+
10
101
  export function pdfSmithPreviewPlugin({ pkgSrcDir }: PreviewPluginOptions): Plugin {
11
102
  return {
12
103
  name: 'pdf-smith-preview',
13
104
 
14
105
  configureServer(server) {
106
+ // Pre-middleware: Export API runs before Vite internals.
107
+ // Must be a sync function — async middleware returns a Promise that
108
+ // Connect ignores, so the pipeline can advance to the catch-all
109
+ // before the async work finishes.
110
+ server.middlewares.use((req, res, next) => {
111
+ if (req.method !== 'POST') return next();
112
+ const match = req.url?.match(/^\/api\/export\/([^/]+)$/);
113
+ if (!match) return next();
114
+ void handleExportRequest(server, match[1], res);
115
+ });
116
+
117
+ // Post-middleware: catch-all HTML handler (after Vite internals)
15
118
  return () => {
16
119
  server.middlewares.use((req, res, next) => {
17
120
  const url = req.url ?? '';
@@ -36,18 +139,35 @@ window.__vite_plugin_react_preamble_installed__ = true
36
139
  <meta charset="UTF-8" />
37
140
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
38
141
  <title>pdf-smith Preview</title>
142
+ <link rel="preconnect" href="https://fonts.googleapis.com">
143
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
144
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,100..900,0..100,0..1&family=Inter:opsz,wght@14..32,100..900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
39
145
  <style>
146
+ *, *::before, *::after { box-sizing: border-box; }
147
+ body {
148
+ margin: 0;
149
+ font-family: 'Inter', system-ui, sans-serif;
150
+ -webkit-font-smoothing: antialiased;
151
+ }
152
+ .pdf-smith-search::placeholder { color: #78716C; }
40
153
  @media print {
41
154
  [data-pdf-smith-nav] { display: none !important; }
155
+ [data-pdf-smith-toolbar] { display: none !important; }
156
+ [data-pdf-smith-minimap] { display: none !important; }
42
157
  [data-pdf-smith-container] {
43
158
  margin-left: 0 !important;
44
159
  padding: 0 !important;
160
+ padding-top: 0 !important;
45
161
  background: none !important;
46
162
  }
47
- [data-pdf-smith-container] > [data-pdf-smith-document] > div {
163
+ [data-pdf-smith-container] > div {
164
+ padding: 0 !important;
165
+ gap: 0 !important;
166
+ }
167
+ [data-pdf-smith-document] > div {
48
168
  margin-bottom: 0 !important;
49
169
  }
50
- [data-pdf-smith-container] > [data-pdf-smith-document] > div > div:first-child {
170
+ [data-pdf-smith-document] > div > div:first-child {
51
171
  display: none !important;
52
172
  }
53
173
  }