@wular/pnext 0.0.22 → 0.0.24
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/package.json +1 -1
- package/src/cli/create.ts +25 -40
- package/src/cli/serve/ui.ts +1 -1
- package/src/cli/template/README.md +10 -0
- package/src/cli/template/app/client/page.tsx +30 -0
- package/src/cli/template/app/globals.css +322 -0
- package/src/cli/template/app/layout.tsx +33 -0
- package/src/cli/template/app/page.tsx +29 -0
- package/src/cli/template/app/server/page.tsx +27 -0
- package/src/cli/template/gitignore +2 -0
- package/src/cli/template/package.json +18 -0
- package/src/cli/template/pnext.config.ts +2 -0
- package/src/cli/template/tsconfig.json +10 -0
- package/src/dev/server.ts +14 -4
- package/src/render/renderer.ts +33 -10
- package/tsconfig.json +1 -1
package/package.json
CHANGED
package/src/cli/create.ts
CHANGED
|
@@ -27,11 +27,11 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
|
|
|
27
27
|
const name = sanitizePackageName(path.basename(target))
|
|
28
28
|
console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
|
|
29
29
|
|
|
30
|
-
const files = scaffoldFiles(name, await latestPnextVersionRange('create'))
|
|
30
|
+
const files = await scaffoldFiles(name, await latestPnextVersionRange('create'))
|
|
31
31
|
await Promise.all(
|
|
32
32
|
Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
|
|
33
33
|
)
|
|
34
|
-
for (const file of Object.keys(files)) console.log(` ${green('+')} ${dim(file)}`)
|
|
34
|
+
for (const file of Object.keys(files).sort()) console.log(` ${green('+')} ${dim(file)}`)
|
|
35
35
|
|
|
36
36
|
let installed = false
|
|
37
37
|
if (options.install) {
|
|
@@ -112,43 +112,28 @@ function printNextSteps(dir: string, installed: boolean) {
|
|
|
112
112
|
console.log(dim(`└${'─'.repeat(width + 2)}┘`))
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
'pnext.config.ts': `// See node_modules/@wular/pnext/reference/config.md for available options.\nexport default {};\n`,
|
|
138
|
-
'tsconfig.json': `${JSON.stringify(
|
|
139
|
-
{
|
|
140
|
-
extends: '@wular/pnext/config/ts/react.json',
|
|
141
|
-
compilerOptions: { jsxImportSource: 'preact', paths: { '#gen/*': ['./.pnext/types/*'] } },
|
|
142
|
-
include: ['**/*.ts', '**/*.tsx', '.pnext/types/**/*.ts'],
|
|
143
|
-
},
|
|
144
|
-
null,
|
|
145
|
-
2,
|
|
146
|
-
)}\n`,
|
|
147
|
-
'app/layout.tsx': `import type { LayoutProps } from '@wular/pnext';\nimport './globals.css';\n\nexport const metadata = {\n title: '${name}',\n};\n\nexport default function RootLayout({ children }: LayoutProps) {\n return (\n <html>\n <body>{children}</body>\n </html>\n );\n}\n`,
|
|
148
|
-
'app/page.tsx': `import Counter from './counter';\n\nexport default async function Home() {\n return (\n <>\n <h1>Welcome to ${name}</h1>\n <p>Edit app/page.tsx to get started.</p>\n <Counter />\n </>\n );\n}\n`,
|
|
149
|
-
'app/counter.tsx': `'use client';\n\nimport { useState } from 'preact/hooks';\n\nexport default function Counter() {\n const [count, setCount] = useState(0);\n return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;\n}\n`,
|
|
150
|
-
'app/globals.css': `body {\n margin: 0;\n font-family: system-ui, sans-serif;\n}\n`,
|
|
151
|
-
'.gitignore': `node_modules\n.pnext\n`,
|
|
152
|
-
'README.md': `# ${name}\n\nA pnext app.\n\n\`\`\`\nbun install\nbun dev\n\`\`\`\n\nDocs: node_modules/@wular/pnext/reference/getting-started.md\n`,
|
|
115
|
+
// The template is a real runnable app in src/cli/template; files are copied verbatim with the
|
|
116
|
+
// placeholder name swapped, and package.json gets the app name and pnext version stamped in.
|
|
117
|
+
const TEMPLATE_DIR = path.join(import.meta.dir, 'template')
|
|
118
|
+
const PLACEHOLDER_NAME = 'pnext-app'
|
|
119
|
+
|
|
120
|
+
async function scaffoldFiles(name: string, pnextRange: string): Promise<Record<string, string>> {
|
|
121
|
+
const files: Record<string, string> = {}
|
|
122
|
+
for (const entry of await readdir(TEMPLATE_DIR, { recursive: true, withFileTypes: true })) {
|
|
123
|
+
if (!entry.isFile()) continue
|
|
124
|
+
const rel = path.relative(TEMPLATE_DIR, path.join(entry.parentPath, entry.name))
|
|
125
|
+
const source = await Bun.file(path.join(TEMPLATE_DIR, rel)).text()
|
|
126
|
+
// npm strips .gitignore from published tarballs, so the template stores it unprefixed.
|
|
127
|
+
if (rel === 'gitignore') {
|
|
128
|
+
files['.gitignore'] = source
|
|
129
|
+
} else if (rel === 'package.json') {
|
|
130
|
+
const pkg = JSON.parse(source) as { name: string; devDependencies: Record<string, string> }
|
|
131
|
+
pkg.name = name
|
|
132
|
+
pkg.devDependencies['@wular/pnext'] = pnextRange
|
|
133
|
+
files[rel] = `${JSON.stringify(pkg, null, 2)}\n`
|
|
134
|
+
} else {
|
|
135
|
+
files[rel] = source.replaceAll(PLACEHOLDER_NAME, name)
|
|
136
|
+
}
|
|
153
137
|
}
|
|
138
|
+
return files
|
|
154
139
|
}
|
package/src/cli/serve/ui.ts
CHANGED
|
@@ -138,7 +138,7 @@ export function openBrowser(url: string) {
|
|
|
138
138
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
139
139
|
const browser = process.env.BROWSER
|
|
140
140
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
141
|
-
if (browser === 'none' || process.env.CI) return
|
|
141
|
+
if (browser === 'none' || process.env.CI || process.env.NODE_ENV === 'test') return
|
|
142
142
|
|
|
143
143
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
144
144
|
const browserArgs = process.env.BROWSER_ARGS?.split(/\s+/).filter(Boolean) ?? []
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'preact/hooks'
|
|
4
|
+
|
|
5
|
+
export default function ClientPage() {
|
|
6
|
+
const [text, setText] = useState('')
|
|
7
|
+
const words = text.trim() ? text.trim().split(/\s+/).length : 0
|
|
8
|
+
return (
|
|
9
|
+
<div class="page">
|
|
10
|
+
<span class="chip">client component</span>
|
|
11
|
+
<h1>Running in the browser</h1>
|
|
12
|
+
<input
|
|
13
|
+
class="field"
|
|
14
|
+
placeholder="Type something…"
|
|
15
|
+
value={text}
|
|
16
|
+
onInput={e => setText(e.currentTarget.value)}
|
|
17
|
+
/>
|
|
18
|
+
<dl class="panel">
|
|
19
|
+
<div>
|
|
20
|
+
<dt>Characters</dt>
|
|
21
|
+
<dd>{text.length}</dd>
|
|
22
|
+
</div>
|
|
23
|
+
<div>
|
|
24
|
+
<dt>Words</dt>
|
|
25
|
+
<dd>{words}</dd>
|
|
26
|
+
</div>
|
|
27
|
+
</dl>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #ffffff;
|
|
3
|
+
--ink: #111111;
|
|
4
|
+
--muted: #6b6b6b;
|
|
5
|
+
--line: #e4e4e4;
|
|
6
|
+
--card: #f7f7f7;
|
|
7
|
+
--accent: #4f46e5;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@media (prefers-color-scheme: dark) {
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0a0a0a;
|
|
13
|
+
--ink: #ededed;
|
|
14
|
+
--muted: #8f8f8f;
|
|
15
|
+
--line: #262626;
|
|
16
|
+
--card: #141414;
|
|
17
|
+
--accent: #8583ff;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* {
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
margin: 0;
|
|
27
|
+
min-height: 100vh;
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
background: var(--bg);
|
|
31
|
+
color: var(--ink);
|
|
32
|
+
font-family: system-ui, sans-serif;
|
|
33
|
+
line-height: 1.5;
|
|
34
|
+
overflow-x: hidden;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* One quiet accent glow, top center. */
|
|
38
|
+
body::before {
|
|
39
|
+
content: '';
|
|
40
|
+
position: fixed;
|
|
41
|
+
z-index: -1;
|
|
42
|
+
top: -24rem;
|
|
43
|
+
left: 50%;
|
|
44
|
+
width: 64rem;
|
|
45
|
+
height: 48rem;
|
|
46
|
+
transform: translateX(-50%);
|
|
47
|
+
border-radius: 50%;
|
|
48
|
+
background: radial-gradient(
|
|
49
|
+
ellipse,
|
|
50
|
+
color-mix(in srgb, var(--accent) 10%, transparent),
|
|
51
|
+
transparent 70%
|
|
52
|
+
);
|
|
53
|
+
filter: blur(60px);
|
|
54
|
+
pointer-events: none;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.container {
|
|
58
|
+
width: min(72rem, 100% - 4rem);
|
|
59
|
+
margin: 0 auto;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.site-header {
|
|
63
|
+
border-bottom: 1px solid var(--line);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.bar {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: space-between;
|
|
70
|
+
padding: 1rem 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.brand {
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 0.5rem;
|
|
77
|
+
font-weight: 650;
|
|
78
|
+
letter-spacing: -0.01em;
|
|
79
|
+
text-decoration: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.mark {
|
|
83
|
+
color: var(--accent);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.site-header nav {
|
|
87
|
+
display: flex;
|
|
88
|
+
gap: 1.75rem;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.site-header nav a {
|
|
92
|
+
color: var(--muted);
|
|
93
|
+
text-decoration: none;
|
|
94
|
+
font-size: 0.92rem;
|
|
95
|
+
font-weight: 500;
|
|
96
|
+
transition: color 130ms ease;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.site-header nav a:hover {
|
|
100
|
+
color: var(--ink);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
main {
|
|
104
|
+
flex: 1;
|
|
105
|
+
display: grid;
|
|
106
|
+
align-content: center;
|
|
107
|
+
padding: 3rem 0 5rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@keyframes rise {
|
|
111
|
+
from {
|
|
112
|
+
opacity: 0;
|
|
113
|
+
transform: translateY(10px);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
to {
|
|
117
|
+
opacity: 1;
|
|
118
|
+
transform: translateY(0);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.hero {
|
|
123
|
+
display: grid;
|
|
124
|
+
grid-template-columns: 1.4fr 1fr;
|
|
125
|
+
align-items: center;
|
|
126
|
+
gap: 4rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.hero > * {
|
|
130
|
+
animation: rise 500ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.hero > :last-child {
|
|
134
|
+
animation-delay: 120ms;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@media (max-width: 52rem) {
|
|
138
|
+
.hero {
|
|
139
|
+
grid-template-columns: 1fr;
|
|
140
|
+
gap: 2.5rem;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
h1 {
|
|
145
|
+
margin: 0 0 1rem;
|
|
146
|
+
font-size: clamp(2.2rem, 4.5vw, 3.4rem);
|
|
147
|
+
font-weight: 700;
|
|
148
|
+
letter-spacing: -0.03em;
|
|
149
|
+
line-height: 1.08;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.lede {
|
|
153
|
+
margin: 0 0 2rem;
|
|
154
|
+
color: var(--muted);
|
|
155
|
+
font-size: 1.15rem;
|
|
156
|
+
max-width: 32rem;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
a {
|
|
160
|
+
color: inherit;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.button-link,
|
|
164
|
+
.field {
|
|
165
|
+
font: inherit;
|
|
166
|
+
font-weight: 500;
|
|
167
|
+
padding: 0.55rem 1.3rem;
|
|
168
|
+
border-radius: 8px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.button-link {
|
|
172
|
+
display: inline-block;
|
|
173
|
+
text-decoration: none;
|
|
174
|
+
background: var(--ink);
|
|
175
|
+
color: var(--bg);
|
|
176
|
+
border: 1px solid var(--ink);
|
|
177
|
+
transition:
|
|
178
|
+
background 150ms ease,
|
|
179
|
+
border-color 150ms ease;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.button-link:hover {
|
|
183
|
+
background: var(--accent);
|
|
184
|
+
border-color: var(--accent);
|
|
185
|
+
color: #ffffff;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.panel {
|
|
189
|
+
margin: 0;
|
|
190
|
+
background: var(--card);
|
|
191
|
+
border: 1px solid var(--line);
|
|
192
|
+
border-radius: 12px;
|
|
193
|
+
max-width: 26rem;
|
|
194
|
+
width: 100%;
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.panel > div,
|
|
199
|
+
.panel > a {
|
|
200
|
+
display: flex;
|
|
201
|
+
align-items: center;
|
|
202
|
+
justify-content: space-between;
|
|
203
|
+
gap: 1.5rem;
|
|
204
|
+
padding: 0.85rem 1.2rem;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.panel > * + * {
|
|
208
|
+
border-top: 1px solid var(--line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.panel > a {
|
|
212
|
+
text-decoration: none;
|
|
213
|
+
transition: background 130ms ease;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.panel > a:hover {
|
|
217
|
+
background: color-mix(in srgb, var(--accent) 8%, var(--card));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.panel .path {
|
|
221
|
+
display: block;
|
|
222
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
223
|
+
font-size: 0.92rem;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.panel .desc {
|
|
227
|
+
display: block;
|
|
228
|
+
margin-top: 0.15rem;
|
|
229
|
+
color: var(--muted);
|
|
230
|
+
font-size: 0.85rem;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.panel .arrow {
|
|
234
|
+
color: var(--muted);
|
|
235
|
+
transition:
|
|
236
|
+
transform 130ms ease,
|
|
237
|
+
color 130ms ease;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.panel > a:hover .arrow {
|
|
241
|
+
color: var(--accent);
|
|
242
|
+
transform: translateX(3px);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.panel dt {
|
|
246
|
+
color: var(--muted);
|
|
247
|
+
font-size: 0.95rem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.panel dd {
|
|
251
|
+
margin: 0;
|
|
252
|
+
font-size: 0.92rem;
|
|
253
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
254
|
+
font-variant-numeric: tabular-nums;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.page {
|
|
258
|
+
max-width: 36rem;
|
|
259
|
+
animation: rise 500ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.chip {
|
|
263
|
+
display: inline-block;
|
|
264
|
+
margin-bottom: 1.1rem;
|
|
265
|
+
padding: 0.18rem 0.7rem;
|
|
266
|
+
border-radius: 999px;
|
|
267
|
+
border: 1px solid var(--line);
|
|
268
|
+
background: var(--card);
|
|
269
|
+
color: var(--muted);
|
|
270
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
271
|
+
font-size: 0.72rem;
|
|
272
|
+
text-transform: uppercase;
|
|
273
|
+
letter-spacing: 0.12em;
|
|
274
|
+
font-weight: 600;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.page h1 {
|
|
278
|
+
margin-bottom: 1.75rem;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.page .panel {
|
|
282
|
+
margin-top: 1.25rem;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.field {
|
|
286
|
+
display: block;
|
|
287
|
+
width: 100%;
|
|
288
|
+
max-width: 24rem;
|
|
289
|
+
background: var(--card);
|
|
290
|
+
color: var(--ink);
|
|
291
|
+
border: 1px solid var(--line);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.field:focus {
|
|
295
|
+
border-color: var(--accent);
|
|
296
|
+
outline: none;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.field::placeholder {
|
|
300
|
+
color: var(--muted);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.hint {
|
|
304
|
+
margin: 0.9rem 0 0;
|
|
305
|
+
min-height: 1.5em;
|
|
306
|
+
color: var(--muted);
|
|
307
|
+
font-size: 0.95rem;
|
|
308
|
+
font-variant-numeric: tabular-nums;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
a:focus-visible {
|
|
312
|
+
outline: 2px solid var(--accent);
|
|
313
|
+
outline-offset: 3px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@media (prefers-reduced-motion: reduce) {
|
|
317
|
+
*,
|
|
318
|
+
*::before {
|
|
319
|
+
animation: none !important;
|
|
320
|
+
transition: none !important;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LayoutProps } from '@wular/pnext'
|
|
2
|
+
import './globals.css'
|
|
3
|
+
|
|
4
|
+
export const metadata = {
|
|
5
|
+
title: 'pnext-app',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
9
|
+
return (
|
|
10
|
+
<html>
|
|
11
|
+
<body>
|
|
12
|
+
<header class="site-header">
|
|
13
|
+
<div class="container bar">
|
|
14
|
+
<a class="brand" href="/">
|
|
15
|
+
<span class="mark">▲</span> pnext-app
|
|
16
|
+
</a>
|
|
17
|
+
<nav>
|
|
18
|
+
<a href="/server">Server component</a>
|
|
19
|
+
<a href="/client">Client component</a>
|
|
20
|
+
<a href="https://www.pnext.dev/docs" target="_blank" rel="noopener">
|
|
21
|
+
Docs
|
|
22
|
+
</a>
|
|
23
|
+
<a href="https://github.com/muzam1l/pnext" target="_blank" rel="noopener">
|
|
24
|
+
GitHub
|
|
25
|
+
</a>
|
|
26
|
+
</nav>
|
|
27
|
+
</div>
|
|
28
|
+
</header>
|
|
29
|
+
<main class="container">{children}</main>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export default async function Home() {
|
|
2
|
+
return (
|
|
3
|
+
<div class="hero">
|
|
4
|
+
<div>
|
|
5
|
+
<h1>Hello, world.</h1>
|
|
6
|
+
<p class="lede">Your app is up and running.</p>
|
|
7
|
+
<a class="button-link" href="https://www.pnext.dev/docs" target="_blank" rel="noopener">
|
|
8
|
+
Read the docs
|
|
9
|
+
</a>
|
|
10
|
+
</div>
|
|
11
|
+
<nav class="panel">
|
|
12
|
+
<a href="/server">
|
|
13
|
+
<span>
|
|
14
|
+
<span class="path">/server</span>
|
|
15
|
+
<span class="desc">Server component, rendered per request</span>
|
|
16
|
+
</span>
|
|
17
|
+
<span class="arrow">→</span>
|
|
18
|
+
</a>
|
|
19
|
+
<a href="/client">
|
|
20
|
+
<span>
|
|
21
|
+
<span class="path">/client</span>
|
|
22
|
+
<span class="desc">Client component, interactive in the browser</span>
|
|
23
|
+
</span>
|
|
24
|
+
<span class="arrow">→</span>
|
|
25
|
+
</a>
|
|
26
|
+
</nav>
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const metadata = {
|
|
2
|
+
title: 'Server component',
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export default async function ServerPage() {
|
|
6
|
+
const renderedAt = new Date().toLocaleTimeString('en-US', { hour12: false })
|
|
7
|
+
return (
|
|
8
|
+
<div class="page">
|
|
9
|
+
<span class="chip">server component</span>
|
|
10
|
+
<h1>Rendered on the server</h1>
|
|
11
|
+
<dl class="panel">
|
|
12
|
+
<div>
|
|
13
|
+
<dt>Rendered at</dt>
|
|
14
|
+
<dd>{renderedAt}</dd>
|
|
15
|
+
</div>
|
|
16
|
+
<div>
|
|
17
|
+
<dt>Runtime</dt>
|
|
18
|
+
<dd>Bun {Bun.version}</dd>
|
|
19
|
+
</div>
|
|
20
|
+
<div>
|
|
21
|
+
<dt>Client JS</dt>
|
|
22
|
+
<dd>none</dd>
|
|
23
|
+
</div>
|
|
24
|
+
</dl>
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pnext-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "pnext dev",
|
|
6
|
+
"build": "pnext build",
|
|
7
|
+
"start": "pnext start",
|
|
8
|
+
"analyze": "pnext analyze"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"preact": "^10"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@wular/pnext": "latest",
|
|
15
|
+
"typescript": "^5",
|
|
16
|
+
"@types/bun": "^1"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/dev/server.ts
CHANGED
|
@@ -32,7 +32,12 @@ import {
|
|
|
32
32
|
serverBundleTargetForRuntime,
|
|
33
33
|
} from '../runtime/loader'
|
|
34
34
|
import { applyProxyResponse, createProxyRunner, validateProxyFiles } from '../routing/proxy'
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
errorLogTrace,
|
|
37
|
+
renderGlobalNotFoundResponse,
|
|
38
|
+
renderPageResponse,
|
|
39
|
+
withDevReloadScript,
|
|
40
|
+
} from '../render/renderer'
|
|
36
41
|
import { markErrorLogged } from '../utils/error-log'
|
|
37
42
|
import { escapeHtml } from '../utils/html'
|
|
38
43
|
import { internalErrorHtml } from './internal-error'
|
|
@@ -792,10 +797,15 @@ function devLastResortResponse(error: unknown, request: Request): Response {
|
|
|
792
797
|
} catch {
|
|
793
798
|
// A malformed URL is exactly the kind of request that gets here; keep the raw text.
|
|
794
799
|
}
|
|
795
|
-
return devLastResortError(error, route, request.url)
|
|
800
|
+
return devLastResortError(error, route, request.url, request)
|
|
796
801
|
}
|
|
797
802
|
|
|
798
|
-
function devLastResortError(
|
|
803
|
+
export function devLastResortError(
|
|
804
|
+
error: unknown,
|
|
805
|
+
route = '(unknown)',
|
|
806
|
+
url = route,
|
|
807
|
+
request?: Request,
|
|
808
|
+
): Response {
|
|
799
809
|
const resolved = error instanceof Error ? error : new Error(String(error))
|
|
800
810
|
if (markErrorLogged(resolved)) console.error(errorLogTrace(resolved))
|
|
801
811
|
let html: string
|
|
@@ -809,7 +819,7 @@ function devLastResortError(error: unknown, route = '(unknown)', url = route): R
|
|
|
809
819
|
} catch {
|
|
810
820
|
html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>pnext error</title></head><body><h1>pnext could not render this request</h1><pre>${escapeHtml(resolved.stack ?? resolved.message)}</pre></body></html>`
|
|
811
821
|
}
|
|
812
|
-
return new Response(html, {
|
|
822
|
+
return new Response(withDevReloadScript(html, request), {
|
|
813
823
|
status: 500,
|
|
814
824
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
815
825
|
})
|
package/src/render/renderer.ts
CHANGED
|
@@ -3060,6 +3060,7 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
|
|
|
3060
3060
|
url: String(options.url),
|
|
3061
3061
|
digest: (resolved as Error & { digest?: string }).digest,
|
|
3062
3062
|
}),
|
|
3063
|
+
options,
|
|
3063
3064
|
)
|
|
3064
3065
|
}
|
|
3065
3066
|
}
|
|
@@ -3161,6 +3162,7 @@ async function renderGlobalErrorPage(
|
|
|
3161
3162
|
withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
|
|
3162
3163
|
options.config,
|
|
3163
3164
|
),
|
|
3165
|
+
options,
|
|
3164
3166
|
)
|
|
3165
3167
|
}
|
|
3166
3168
|
try {
|
|
@@ -3190,6 +3192,7 @@ async function renderGlobalErrorPage(
|
|
|
3190
3192
|
withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
|
|
3191
3193
|
options.config,
|
|
3192
3194
|
),
|
|
3195
|
+
options,
|
|
3193
3196
|
)
|
|
3194
3197
|
} catch {
|
|
3195
3198
|
const defaultUi = nextCompatEnabled(options.config)
|
|
@@ -3202,6 +3205,7 @@ async function renderGlobalErrorPage(
|
|
|
3202
3205
|
withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
|
|
3203
3206
|
options.config,
|
|
3204
3207
|
),
|
|
3208
|
+
options,
|
|
3205
3209
|
)
|
|
3206
3210
|
}
|
|
3207
3211
|
}
|
|
@@ -3216,7 +3220,19 @@ async function renderGlobalErrorPage(
|
|
|
3216
3220
|
function withGlobalErrorReportScript(html: string, error: SerializedError): string {
|
|
3217
3221
|
const script = getRenderExtensions().globalErrorReportScript(error)
|
|
3218
3222
|
if (!script) return html
|
|
3219
|
-
return
|
|
3223
|
+
return appendToDocumentBody(html, script)
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
function appendToDocumentBody(html: string, content: string): string {
|
|
3227
|
+
const closingBodies = [...html.matchAll(/<\/body\s*>/gi)]
|
|
3228
|
+
const closingBody = closingBodies.at(-1)?.index
|
|
3229
|
+
if (closingBody === undefined) return html + content
|
|
3230
|
+
return html.slice(0, closingBody) + content + html.slice(closingBody)
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
/** Keep a standalone development document connected to the dev server's reload stream. */
|
|
3234
|
+
export function withDevReloadScript(html: string, request?: Request): string {
|
|
3235
|
+
return appendToDocumentBody(html, devReloadScript(documentNonce(request)))
|
|
3220
3236
|
}
|
|
3221
3237
|
|
|
3222
3238
|
function withGlobalErrorStylesheet(html: string, config: ResolvedConfig): string {
|
|
@@ -3227,13 +3243,16 @@ function withGlobalErrorStylesheet(html: string, config: ResolvedConfig): string
|
|
|
3227
3243
|
return html.replace(/<html(?:\s[^>]*)?>/i, tag => `${tag}<head>${link}</head>`)
|
|
3228
3244
|
}
|
|
3229
3245
|
|
|
3230
|
-
// A pre-rendered document (its own <html>/<body>)
|
|
3231
|
-
//
|
|
3232
|
-
|
|
3233
|
-
|
|
3246
|
+
// A pre-rendered document (its own <html>/<body>). The ordinary document shell
|
|
3247
|
+
// is skipped entirely, so development adds its reload client directly.
|
|
3248
|
+
function globalErrorPage(
|
|
3249
|
+
html: string,
|
|
3250
|
+
options: Pick<RenderOptions, 'dev' | 'request'>,
|
|
3251
|
+
): PageRender {
|
|
3252
|
+
const body = options.dev ? withDevReloadScript(html, options.request) : html
|
|
3234
3253
|
return {
|
|
3235
3254
|
shell: 'global-error',
|
|
3236
|
-
body
|
|
3255
|
+
body,
|
|
3237
3256
|
htmlProps: {},
|
|
3238
3257
|
bodyProps: {},
|
|
3239
3258
|
head: '',
|
|
@@ -4535,15 +4554,17 @@ async function renderTreeOrFallback(
|
|
|
4535
4554
|
try {
|
|
4536
4555
|
return await renderTree(options, tree, stream, metadata, extra)
|
|
4537
4556
|
} catch {
|
|
4538
|
-
return generatedFallbackPage(tree, metadata, extra.status ?? 500)
|
|
4557
|
+
return generatedFallbackPage(options, tree, metadata, extra.status ?? 500)
|
|
4539
4558
|
}
|
|
4540
4559
|
}
|
|
4541
4560
|
|
|
4542
4561
|
function generatedFallbackPage(
|
|
4562
|
+
options: RenderOptions,
|
|
4543
4563
|
tree: ComponentChildren,
|
|
4544
4564
|
metadata: Metadata,
|
|
4545
4565
|
status: number,
|
|
4546
4566
|
): PageRender {
|
|
4567
|
+
const nonce = documentNonce(options.request)
|
|
4547
4568
|
return {
|
|
4548
4569
|
shell: 'generated',
|
|
4549
4570
|
body: serializeNeutralClientIslands(renderToString(h(Fragment, null, tree))),
|
|
@@ -4557,11 +4578,12 @@ function generatedFallbackPage(
|
|
|
4557
4578
|
propsScript: '',
|
|
4558
4579
|
routeScript: '',
|
|
4559
4580
|
clientScript: '',
|
|
4560
|
-
devScript: '',
|
|
4581
|
+
devScript: options.dev ? devReloadScript() : '',
|
|
4561
4582
|
streamChunks: [],
|
|
4562
4583
|
status,
|
|
4563
4584
|
viewport: {},
|
|
4564
4585
|
fontCss: '',
|
|
4586
|
+
...(nonce ? { nonce } : {}),
|
|
4565
4587
|
}
|
|
4566
4588
|
}
|
|
4567
4589
|
|
|
@@ -7679,12 +7701,13 @@ function componentChildren(value: ServerComponentResult): ComponentChildren {
|
|
|
7679
7701
|
|
|
7680
7702
|
// data-pnext-dev tells the soft-navigation runtime to skip this script on
|
|
7681
7703
|
// body swaps — the current document already holds an open event stream.
|
|
7682
|
-
function devReloadScript() {
|
|
7704
|
+
function devReloadScript(nonce?: string) {
|
|
7683
7705
|
// css-update swaps every same-origin stylesheet for a cache-busted copy and
|
|
7684
7706
|
// drops the old one once the new one has loaded, so the page never flashes
|
|
7685
7707
|
// unstyled and never loses client state. Anything the server could not prove
|
|
7686
7708
|
// was CSS-only still arrives as `reload`.
|
|
7687
|
-
|
|
7709
|
+
const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
|
|
7710
|
+
return `<script${nonceAttribute} data-pnext-dev>
|
|
7688
7711
|
let events;
|
|
7689
7712
|
let generation;
|
|
7690
7713
|
// The stream is a long-lived connection counted against the browser's per-origin
|
package/tsconfig.json
CHANGED