@wular/pnext 0.0.21 → 0.0.23
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/entry.ts +14 -4
- package/src/cli/serve/pipeline.ts +4 -0
- package/src/cli/serve/ui.ts +1 -1
- package/src/cli/start.ts +9 -0
- 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/cli/typegen.ts +4 -1
- package/src/client/router/runtime.ts +20 -6
- package/src/compat/metadata.ts +44 -2
- package/src/compat/next/config-loader.ts +7 -1
- package/src/compat/register/typed-routes.ts +4 -0
- package/src/compat/typecheck/check.ts +5 -1
- package/src/config.ts +7 -0
- package/src/dev/server.ts +16 -4
- package/src/extensions.ts +7 -1
- package/src/render/metadata.ts +53 -31
- package/src/render/renderer.ts +33 -10
- package/src/resolve/imports.ts +7 -1
- package/src/resolve/scan-facts.ts +7 -1
- package/src/runtime/fetch-host.ts +46 -2
- package/src/types.ts +84 -67
- package/tsconfig.json +1 -1
|
@@ -4810,9 +4810,11 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
4810
4810
|
// Same-entry-identity navigation (a search-param nav on the same pathname, a refresh):
|
|
4811
4811
|
// `key={bfcacheId}` subtrees do NOT remount, so typed values must survive. The commit can
|
|
4812
4812
|
// still rebuild the page DOM under them (a PPR route streams its page slot in AFTER the
|
|
4813
|
-
// swap), so re-apply the departure snapshot as the controls appear
|
|
4813
|
+
// swap), so re-apply the departure snapshot as the controls appear - but only to the DOM
|
|
4814
|
+
// this swap replaced, never to a control a live island's own render owns (see
|
|
4815
|
+
// islandOwnedControl).
|
|
4814
4816
|
else if (departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
|
|
4815
|
-
restoreFormStateWhenMounted(departingBfcacheId, sequence)
|
|
4817
|
+
restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
|
|
4816
4818
|
}
|
|
4817
4819
|
|
|
4818
4820
|
// A navigation that only changed parallel slots (same children path AND unchanged search
|
|
@@ -4907,13 +4909,25 @@ function restoreControl(
|
|
|
4907
4909
|
if (control.value !== saved.value) control.value = saved.value
|
|
4908
4910
|
}
|
|
4909
4911
|
|
|
4910
|
-
|
|
4912
|
+
/**
|
|
4913
|
+
* A control inside a mounted island is owned by that island's preact diff, which reproduces
|
|
4914
|
+
* React's reconciliation - including an app key (`key={searchParams.get('q')}`) that
|
|
4915
|
+
* deliberately remounts the subtree with an EMPTY control. Re-applying the departure snapshot
|
|
4916
|
+
* there fights the app; only DOM the raw swap replaced is pnext's own loss to compensate.
|
|
4917
|
+
*/
|
|
4918
|
+
function islandOwnedControl(control: Element): boolean {
|
|
4919
|
+
const root: LiveIslandRoot | null = control.closest('pnext-client[data-pnext-client]')
|
|
4920
|
+
return Boolean(root?.__pnextLive)
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4923
|
+
function restoreFormState(bfcacheId: string | undefined, onlyEmpty = false, skipIslands = false) {
|
|
4911
4924
|
if (!bfcacheId) return
|
|
4912
4925
|
const state = entryFormStateCache.get(bfcacheId)
|
|
4913
4926
|
if (!state) return
|
|
4914
4927
|
const controls = formControls()
|
|
4915
4928
|
if (controls.length !== state.length) return
|
|
4916
4929
|
for (const [index, control] of controls.entries()) {
|
|
4930
|
+
if (skipIslands && islandOwnedControl(control)) continue
|
|
4917
4931
|
// `onlyEmpty`: a freshly mounted control is empty, so filling it restores
|
|
4918
4932
|
// what the remount lost — while anything already carrying a value (the app
|
|
4919
4933
|
// set it, or the user typed after the commit) is left alone.
|
|
@@ -4951,10 +4965,10 @@ function restoreSharedLayoutFormState(
|
|
|
4951
4965
|
* describes can appear after the swap. Keep trying while the DOM changes until the control
|
|
4952
4966
|
* set matches the snapshot.
|
|
4953
4967
|
*/
|
|
4954
|
-
function restoreFormStateWhenMounted(bfcacheId: string, sequence: number)
|
|
4968
|
+
function restoreFormStateWhenMounted(bfcacheId: string, sequence: number, skipIslands = false) {
|
|
4955
4969
|
const state = entryFormStateCache.get(bfcacheId)
|
|
4956
4970
|
if (!state) return
|
|
4957
|
-
restoreFormState(bfcacheId, true)
|
|
4971
|
+
restoreFormState(bfcacheId, true, skipIslands)
|
|
4958
4972
|
// The controls can be rebuilt more than once while the commit settles, so the restore stays
|
|
4959
4973
|
// armed for a short window instead of firing once. It only ever fills EMPTY controls, so a
|
|
4960
4974
|
// value the app or the user set after the commit is never clobbered.
|
|
@@ -4963,7 +4977,7 @@ function restoreFormStateWhenMounted(bfcacheId: string, sequence: number): void
|
|
|
4963
4977
|
observer.disconnect()
|
|
4964
4978
|
return
|
|
4965
4979
|
}
|
|
4966
|
-
restoreFormState(bfcacheId, true)
|
|
4980
|
+
restoreFormState(bfcacheId, true, skipIslands)
|
|
4967
4981
|
})
|
|
4968
4982
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
4969
4983
|
window.setTimeout(() => observer.disconnect(), FORM_STATE_RESTORE_WINDOW_MS)
|
package/src/compat/metadata.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Metadata, MetadataOpenGraph, PageProps, RouteManifestEntry, Viewport } from '../types'
|
|
2
2
|
import { staticMetadataForPathFromFiles, type StaticMetadataFile } from '../routing/metadata-files'
|
|
3
3
|
import { coerceString } from '../render/metadata'
|
|
4
|
+
import { legacyRequestAPIs } from '../request/context'
|
|
4
5
|
import { readSourceText } from '../resolve/source-text'
|
|
5
6
|
import { SPAN_TYPE, withChildSpan } from './otel/tracer'
|
|
6
7
|
import { settledPromise } from '../render/renderer'
|
|
@@ -146,7 +147,9 @@ function resolvedParentMetadata(parent: Metadata): Metadata {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
function resolvedParentOpenGraph(
|
|
150
|
+
function resolvedParentOpenGraph(
|
|
151
|
+
openGraph: MetadataOpenGraph | null | undefined,
|
|
152
|
+
): MetadataOpenGraph {
|
|
150
153
|
if (!openGraph) return {}
|
|
151
154
|
return {
|
|
152
155
|
...openGraph,
|
|
@@ -165,7 +168,7 @@ function resolvedParentOpenGraph(openGraph: MetadataOpenGraph | undefined): Meta
|
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
function normalizeKeywordList(value: Metadata['keywords']): (string | number)[] | undefined {
|
|
168
|
-
if (value === undefined) return undefined
|
|
171
|
+
if (value === undefined || value === null) return undefined
|
|
169
172
|
return Array.isArray(value) ? value : [value]
|
|
170
173
|
}
|
|
171
174
|
|
|
@@ -192,6 +195,14 @@ export async function warnNextMetadataBuildIssues(
|
|
|
192
195
|
if (route.kind !== 'page') continue
|
|
193
196
|
const routeFacts = await Promise.all(routeSourceFiles(appPath, route).map(factsFor))
|
|
194
197
|
const some = (pick: (fact: MetadataSourceFacts) => boolean) => routeFacts.some(pick)
|
|
198
|
+
// themeColor-in-metadata deprecation is Next 15+; legacy (Next-14 era) apps never saw it.
|
|
199
|
+
if (!legacyRequestAPIs() && some(fact => fact.themeColor)) {
|
|
200
|
+
warnOnce(
|
|
201
|
+
warned,
|
|
202
|
+
`themeColor:${route.route}`,
|
|
203
|
+
`Unsupported metadata themeColor is configured in metadata export in ${route.route || '/'}. Please move it to viewport\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
195
206
|
if (some(fact => fact.appleTouchFullscreen)) {
|
|
196
207
|
warnOnce(
|
|
197
208
|
warned,
|
|
@@ -239,6 +250,7 @@ async function readSourceTextOrEmpty(file: string) {
|
|
|
239
250
|
* nearly every file has none of these keys.
|
|
240
251
|
*/
|
|
241
252
|
interface MetadataSourceFacts {
|
|
253
|
+
themeColor: boolean
|
|
242
254
|
appleTouchFullscreen: boolean
|
|
243
255
|
appleTouchIconPrecomposed: boolean
|
|
244
256
|
metadataBase: boolean
|
|
@@ -247,6 +259,7 @@ interface MetadataSourceFacts {
|
|
|
247
259
|
|
|
248
260
|
function metadataSourceFacts(source: string): MetadataSourceFacts {
|
|
249
261
|
return {
|
|
262
|
+
themeColor: themeColorInMetadataExport(source),
|
|
250
263
|
appleTouchFullscreen:
|
|
251
264
|
source.includes('apple-touch-fullscreen') && /['"]apple-touch-fullscreen['"]/.test(source),
|
|
252
265
|
appleTouchIconPrecomposed:
|
|
@@ -257,6 +270,35 @@ function metadataSourceFacts(source: string): MetadataSourceFacts {
|
|
|
257
270
|
}
|
|
258
271
|
}
|
|
259
272
|
|
|
273
|
+
// A themeColor inside a viewport export is where the notice says to put it, so
|
|
274
|
+
// only occurrences outside viewport/generateViewport blocks count.
|
|
275
|
+
function themeColorInMetadataExport(source: string): boolean {
|
|
276
|
+
if (!source.includes('themeColor') || !/\bthemeColor\s*:/.test(source)) return false
|
|
277
|
+
return /\bthemeColor\s*:/.test(stripViewportExports(source))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stripViewportExports(source: string): string {
|
|
281
|
+
const starts =
|
|
282
|
+
/export\s+(?:const|let|var)\s+viewport\b|export\s+(?:async\s+)?function\s+generateViewport\b/g
|
|
283
|
+
let out = ''
|
|
284
|
+
let last = 0
|
|
285
|
+
for (let match = starts.exec(source); match; match = starts.exec(source)) {
|
|
286
|
+
const open = source.indexOf('{', match.index)
|
|
287
|
+
if (open === -1) break
|
|
288
|
+
let depth = 0
|
|
289
|
+
let end = open
|
|
290
|
+
for (; end < source.length; end++) {
|
|
291
|
+
const char = source[end]
|
|
292
|
+
if (char === '{') depth++
|
|
293
|
+
else if (char === '}' && --depth === 0) break
|
|
294
|
+
}
|
|
295
|
+
out += source.slice(last, match.index)
|
|
296
|
+
last = end + 1
|
|
297
|
+
starts.lastIndex = last
|
|
298
|
+
}
|
|
299
|
+
return out + source.slice(last)
|
|
300
|
+
}
|
|
301
|
+
|
|
260
302
|
function routeUsesRelativeSocialImage(
|
|
261
303
|
route: RouteManifestEntry,
|
|
262
304
|
facts: MetadataSourceFacts[],
|
|
@@ -112,10 +112,16 @@ function findConfigPath(root: string): string | undefined {
|
|
|
112
112
|
return undefined
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
|
|
116
|
+
// bun-types typecheck this file and a bare `Bun` identifier would fail them.
|
|
117
|
+
const { Bun: bun } = globalThis as unknown as {
|
|
118
|
+
Bun: { hash(input: string | ArrayBufferView): { toString(radix?: number): string } }
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
/** Stable per config content, so the emitted bundle keeps one name per boot. */
|
|
116
122
|
function configSourceKey(configPath: string): string {
|
|
117
123
|
try {
|
|
118
|
-
return
|
|
124
|
+
return bun.hash(readFileSync(configPath)).toString(36)
|
|
119
125
|
} catch {
|
|
120
126
|
return 'config'
|
|
121
127
|
}
|
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
registerBuildSteps,
|
|
10
10
|
type BuildStepContext,
|
|
11
11
|
} from '../../extensions'
|
|
12
|
+
import { legacyRequestAPIs } from '../../request/context'
|
|
12
13
|
import type { RouteManifestEntry } from '../../types'
|
|
13
14
|
import { nextCompatEnabled } from '../aliases'
|
|
15
|
+
import { writeTsconfigDefaults } from '../tsconfig-defaults'
|
|
14
16
|
import { typecheckDisabled, validateTypesOffThread } from '../typecheck/check'
|
|
15
17
|
import { generateTypedRoutes } from '../typed-routes/generate'
|
|
16
18
|
|
|
@@ -20,6 +22,8 @@ export function registerTypedRoutesExtensions(_config: ResolvedConfig): void {
|
|
|
20
22
|
|
|
21
23
|
async function runTypedRoutesBuildStep(ctx: BuildStepContext): Promise<void> {
|
|
22
24
|
if (!nextCompatEnabled(ctx.config)) return
|
|
25
|
+
// Legacy (Next-14 era) apps keep their tsconfig untouched; current apps get Next's defaults.
|
|
26
|
+
if (!legacyRequestAPIs()) await writeTsconfigDefaults(ctx.config)
|
|
23
27
|
await generateTypedRoutes(ctx.config, ctx.routes as RouteManifestEntry[])
|
|
24
28
|
// The typecheck reads only files that already exist on disk, so it runs on a worker thread
|
|
25
29
|
// alongside client bundling and prerendering rather than ahead of them; the CLI awaits it before
|
|
@@ -372,6 +372,10 @@ async function writeTypecheckProject(
|
|
|
372
372
|
},
|
|
373
373
|
include: [
|
|
374
374
|
path.join(dir, 'next-shims.d.ts'),
|
|
375
|
+
// Generated per-route assignability checks (typegen writes them next to this
|
|
376
|
+
// project, not under the app-included `types/` dir, so only pnext's own compiler
|
|
377
|
+
// reads them). Included by hand to keep that coverage.
|
|
378
|
+
path.join(dir, 'checks', '**/*.ts'),
|
|
375
379
|
...(nodeTypes ? [path.join(nodeTypes, 'index.d.ts')] : []),
|
|
376
380
|
path.join(config.root, 'next-env.d.ts'),
|
|
377
381
|
// The build emits `.next/types/validator.ts` (page/layout/route-handler export validation) just
|
|
@@ -661,7 +665,7 @@ declare module 'react/jsx-runtime' {
|
|
|
661
665
|
`
|
|
662
666
|
const requestType = (type: string) =>
|
|
663
667
|
legacyRequestTypes ? `Promise<${type}> & ${type}` : `Promise<${type}>`
|
|
664
|
-
const requestShims = `declare module 'next/headers'{interface CookieSetOptions{name?:string;value?:string;path?:string;maxAge?:number;expires?:Date|number;httpOnly?:boolean;secure?:boolean;sameSite?:'strict'|'lax'|'none'|boolean;domain?:string}interface CookieStore{get(name:string):{name:string;value:string}|undefined;get(options:{name:string;value?:string}):{name:string;value:string}|undefined;getAll(name?:string):{name:string;value:string}[];getAll(options:{name:string;value?:string}):{name:string;value:string}[];has(name:string):boolean;readonly size:number;set(name:string,value:string,options?:CookieSetOptions):CookieStore;
|
|
668
|
+
const requestShims = `declare module 'next/headers'{interface CookieSetOptions{name?:string;value?:string;path?:string;maxAge?:number;expires?:Date|number;httpOnly?:boolean;secure?:boolean;sameSite?:'strict'|'lax'|'none'|boolean;domain?:string}interface CookieStore{get(name:string):{name:string;value:string}|undefined;get(options:{name:string;value?:string}):{name:string;value:string}|undefined;getAll(name?:string):{name:string;value:string}[];getAll(options:{name:string;value?:string}):{name:string;value:string}[];has(name:string):boolean;readonly size:number;set(options:CookieSetOptions):CookieStore;set(name:string,value:string,options?:CookieSetOptions):CookieStore;delete(name:string):boolean;clear():CookieStore;[Symbol.iterator]():IterableIterator<readonly[string,{name?:string;value:string}]>}export function cookies():${requestType('CookieStore')};export function headers():${requestType('Headers')};export function draftMode():${requestType(`{isEnabled:boolean;enable():void;disable():void}`)}}
|
|
665
669
|
`
|
|
666
670
|
const fontShims = hasRealNext
|
|
667
671
|
? ''
|
package/src/config.ts
CHANGED
|
@@ -21,6 +21,12 @@ export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
|
|
|
21
21
|
outRootPath: string
|
|
22
22
|
/** Generated types — always under the out root, because tsconfig points at it. */
|
|
23
23
|
typesPath: string
|
|
24
|
+
/**
|
|
25
|
+
* Generated per-route assignability checks. Deliberately OUTSIDE typesPath: an app's
|
|
26
|
+
* tsconfig includes `<outRoot>/types/**` and a foreign compiler must never see pnext's
|
|
27
|
+
* own internals. pnext's private typecheck project includes this directory instead.
|
|
28
|
+
*/
|
|
29
|
+
checksPath: string
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -146,6 +152,7 @@ export async function loadConfig(
|
|
|
146
152
|
outPath: options.dev ? path.join(outRootPath, devOutSegment) : outRootPath,
|
|
147
153
|
outRootPath,
|
|
148
154
|
typesPath: path.join(outRootPath, 'types'),
|
|
155
|
+
checksPath: path.join(outRootPath, 'typecheck', 'checks'),
|
|
149
156
|
}
|
|
150
157
|
}
|
|
151
158
|
|
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'
|
|
@@ -100,6 +105,7 @@ import {
|
|
|
100
105
|
} from '../runtime/modules'
|
|
101
106
|
import { ssrClientReference } from '../client/reference'
|
|
102
107
|
import { cacheRoot, setDevWatcherFreshness } from '../runtime/module-cache'
|
|
108
|
+
import { abortUpstreamFetchOnDisconnect } from '../runtime/fetch-host'
|
|
103
109
|
import { markBoot } from '../cli/boot/trace'
|
|
104
110
|
import { formatDuration } from '../utils/verbose'
|
|
105
111
|
|
|
@@ -454,6 +460,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
454
460
|
syncWatchRoots()
|
|
455
461
|
}, 0)
|
|
456
462
|
if (profile) flushDevProfileLines()
|
|
463
|
+
abortUpstreamFetchOnDisconnect(logged.body, request.signal)
|
|
457
464
|
return flushWorkUnitOnClose(logged, unit, request.signal)
|
|
458
465
|
}
|
|
459
466
|
|
|
@@ -790,10 +797,15 @@ function devLastResortResponse(error: unknown, request: Request): Response {
|
|
|
790
797
|
} catch {
|
|
791
798
|
// A malformed URL is exactly the kind of request that gets here; keep the raw text.
|
|
792
799
|
}
|
|
793
|
-
return devLastResortError(error, route, request.url)
|
|
800
|
+
return devLastResortError(error, route, request.url, request)
|
|
794
801
|
}
|
|
795
802
|
|
|
796
|
-
function devLastResortError(
|
|
803
|
+
export function devLastResortError(
|
|
804
|
+
error: unknown,
|
|
805
|
+
route = '(unknown)',
|
|
806
|
+
url = route,
|
|
807
|
+
request?: Request,
|
|
808
|
+
): Response {
|
|
797
809
|
const resolved = error instanceof Error ? error : new Error(String(error))
|
|
798
810
|
if (markErrorLogged(resolved)) console.error(errorLogTrace(resolved))
|
|
799
811
|
let html: string
|
|
@@ -807,7 +819,7 @@ function devLastResortError(error: unknown, route = '(unknown)', url = route): R
|
|
|
807
819
|
} catch {
|
|
808
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>`
|
|
809
821
|
}
|
|
810
|
-
return new Response(html, {
|
|
822
|
+
return new Response(withDevReloadScript(html, request), {
|
|
811
823
|
status: 500,
|
|
812
824
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
813
825
|
})
|
package/src/extensions.ts
CHANGED
|
@@ -15,6 +15,12 @@ import type { BuildManifest, ClientEntryReason, RouteManifestEntry, RouteParamVa
|
|
|
15
15
|
import type { VerboseLogger } from './utils/verbose'
|
|
16
16
|
import { escapeRegex } from './utils/code'
|
|
17
17
|
|
|
18
|
+
// `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
|
|
19
|
+
// bun-types typecheck this file and a bare `Bun` identifier would fail them.
|
|
20
|
+
const { Bun: bun } = globalThis as unknown as {
|
|
21
|
+
Bun: { hash(input: string | ArrayBufferView): { toString(radix?: number): string } }
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
/** Per-build wiring for the server plugin chain. */
|
|
19
25
|
export interface ServerEsbuildPluginOptions {
|
|
20
26
|
/**
|
|
@@ -1416,7 +1422,7 @@ export function classifyRouteDependencies(
|
|
|
1416
1422
|
|
|
1417
1423
|
/** Every reason a registered detector requires a client entry for this source. */
|
|
1418
1424
|
export function sourceClientEntryReasons(source: string): ClientEntryReason[] {
|
|
1419
|
-
const key = `${source.length}\0${
|
|
1425
|
+
const key = `${source.length}\0${bun.hash(source).toString(36)}`
|
|
1420
1426
|
const cached = clientEntryReasonCache.get(key)
|
|
1421
1427
|
if (cached) return cached
|
|
1422
1428
|
const reasons: ClientEntryReason[] = []
|
package/src/render/metadata.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
MetadataLink,
|
|
8
8
|
MetadataOpenGraph,
|
|
9
9
|
MetadataRobots,
|
|
10
|
+
MetadataValue,
|
|
10
11
|
ResourceHint,
|
|
11
12
|
MetadataTwitter,
|
|
12
13
|
Viewport,
|
|
@@ -19,6 +20,13 @@ import {
|
|
|
19
20
|
import { escapeHtml, stringifyHtmlValue } from '../utils/html'
|
|
20
21
|
import { getTrailingSlashUrls, withBasePath } from '../routing/href'
|
|
21
22
|
|
|
23
|
+
/** Every metadata field is nullable: null means "not set", exactly like undefined. */
|
|
24
|
+
type MetaContent = MetadataValue | null | undefined
|
|
25
|
+
|
|
26
|
+
function absent(value: unknown): value is null | undefined {
|
|
27
|
+
return value === undefined || value === null
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
export type MetadataExport = Metadata | (() => Metadata | Promise<Metadata>)
|
|
23
31
|
|
|
24
32
|
export type ViewportExport = Viewport | (() => Viewport | Promise<Viewport>)
|
|
@@ -224,12 +232,15 @@ function prefixImageBasePath(image: MetadataImage): MetadataImage {
|
|
|
224
232
|
export function metadataTags(metadata: Metadata, context: MetadataContext): string {
|
|
225
233
|
const meta = metadata
|
|
226
234
|
const tags: string[] = []
|
|
227
|
-
const push = (name: string, content:
|
|
228
|
-
if (content)
|
|
235
|
+
const push = (name: string, content: MetaContent) => {
|
|
236
|
+
if (content)
|
|
237
|
+
tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(coerceString(content))}"/>`)
|
|
229
238
|
}
|
|
230
|
-
const pushProperty = (property: string, content:
|
|
239
|
+
const pushProperty = (property: string, content: MetaContent) => {
|
|
231
240
|
if (content)
|
|
232
|
-
tags.push(
|
|
241
|
+
tags.push(
|
|
242
|
+
`<meta property="${escapeHtml(property)}" content="${escapeHtml(coerceString(content))}"/>`,
|
|
243
|
+
)
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
push('description', meta.description)
|
|
@@ -263,8 +274,8 @@ export function metadataTags(metadata: Metadata, context: MetadataContext): stri
|
|
|
263
274
|
}
|
|
264
275
|
if (meta.facebook?.appId) pushProperty('fb:app_id', meta.facebook.appId)
|
|
265
276
|
for (const admin of list(meta.facebook?.admins)) pushProperty('fb:admins', admin)
|
|
266
|
-
if (meta.pinterest?.richPin
|
|
267
|
-
pushProperty('pinterest-rich-pin', meta.pinterest.richPin
|
|
277
|
+
if (!absent(meta.pinterest?.richPin)) {
|
|
278
|
+
pushProperty('pinterest-rich-pin', String(meta.pinterest.richPin))
|
|
268
279
|
}
|
|
269
280
|
for (const [property, value] of appLinkTags(meta.appLinks)) pushProperty(property, value)
|
|
270
281
|
|
|
@@ -278,13 +289,15 @@ export function metadataTags(metadata: Metadata, context: MetadataContext): stri
|
|
|
278
289
|
function openGraphTags(og: MetadataOpenGraph | undefined, context: MetadataContext): string[] {
|
|
279
290
|
if (!og) return []
|
|
280
291
|
const tags: string[] = []
|
|
281
|
-
const push = (property: string, content:
|
|
292
|
+
const push = (property: string, content: MetaContent) => {
|
|
282
293
|
if (content)
|
|
283
|
-
tags.push(
|
|
294
|
+
tags.push(
|
|
295
|
+
`<meta property="${escapeHtml(property)}" content="${escapeHtml(coerceString(content))}"/>`,
|
|
296
|
+
)
|
|
284
297
|
}
|
|
285
298
|
push('og:title', og.title === undefined ? undefined : resolveTitleChain([{ title: og.title }]))
|
|
286
299
|
push('og:description', og.description)
|
|
287
|
-
push('og:url',
|
|
300
|
+
push('og:url', resolveMetadataUrl(og.url, context))
|
|
288
301
|
push('og:site_name', og.siteName)
|
|
289
302
|
push('og:locale', og.locale)
|
|
290
303
|
push('og:type', og.type)
|
|
@@ -313,8 +326,9 @@ function openGraphTags(og: MetadataOpenGraph | undefined, context: MetadataConte
|
|
|
313
326
|
function twitterTags(twitter: MetadataTwitter | undefined, context: MetadataContext): string[] {
|
|
314
327
|
if (!twitter) return []
|
|
315
328
|
const tags: string[] = []
|
|
316
|
-
const push = (name: string, content:
|
|
317
|
-
if (content)
|
|
329
|
+
const push = (name: string, content: MetaContent) => {
|
|
330
|
+
if (content)
|
|
331
|
+
tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(coerceString(content))}"/>`)
|
|
318
332
|
}
|
|
319
333
|
push('twitter:card', twitterCard(twitter))
|
|
320
334
|
push('twitter:site', twitter.site)
|
|
@@ -334,10 +348,13 @@ function twitterTags(twitter: MetadataTwitter | undefined, context: MetadataCont
|
|
|
334
348
|
push('twitter:image:alt', image.alt)
|
|
335
349
|
push('twitter:image:type', image.type)
|
|
336
350
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
351
|
+
const players = twitter.players === undefined ? [] : [twitter.players].flat()
|
|
352
|
+
for (const player of players) {
|
|
353
|
+
push('twitter:player', resolveMetadataUrl(player.playerUrl, context))
|
|
354
|
+
push('twitter:player:stream', resolveMetadataUrl(player.streamUrl, context))
|
|
355
|
+
push('twitter:player:width', coerceString(player.width) || undefined)
|
|
356
|
+
push('twitter:player:height', coerceString(player.height) || undefined)
|
|
357
|
+
}
|
|
341
358
|
for (const [device, id] of Object.entries(twitter.app?.id ?? {})) {
|
|
342
359
|
push(`twitter:app:id:${device}`, id)
|
|
343
360
|
}
|
|
@@ -349,7 +366,7 @@ function twitterTags(twitter: MetadataTwitter | undefined, context: MetadataCont
|
|
|
349
366
|
|
|
350
367
|
// theme-color: string | { color, media } | array of those.
|
|
351
368
|
export function themeColorTags(value: Viewport['themeColor']): string {
|
|
352
|
-
if (value
|
|
369
|
+
if (absent(value)) return ''
|
|
353
370
|
const list = Array.isArray(value) ? value : [value]
|
|
354
371
|
return list
|
|
355
372
|
.map(entry => {
|
|
@@ -514,13 +531,13 @@ function coerceKeywords(value: Metadata['keywords']): string | undefined {
|
|
|
514
531
|
}
|
|
515
532
|
|
|
516
533
|
function normalizeKeywordList(value: Metadata['keywords']): (string | number)[] | undefined {
|
|
517
|
-
if (value
|
|
534
|
+
if (absent(value)) return undefined
|
|
518
535
|
return Array.isArray(value) ? value : [value]
|
|
519
536
|
}
|
|
520
537
|
|
|
521
538
|
// authors -> list of display names for meta[name=author].
|
|
522
539
|
function coerceAuthors(value: Metadata['authors']): string[] {
|
|
523
|
-
if (value
|
|
540
|
+
if (absent(value)) return []
|
|
524
541
|
const list = Array.isArray(value) ? value : [value]
|
|
525
542
|
return list
|
|
526
543
|
.map(author => (typeof author === 'string' ? author : author.name))
|
|
@@ -530,7 +547,7 @@ function coerceAuthors(value: Metadata['authors']): string[] {
|
|
|
530
547
|
|
|
531
548
|
// robots: string passthrough, or object -> "index, follow, noarchive" style.
|
|
532
549
|
function coerceRobots(value: Metadata['robots']): string | undefined {
|
|
533
|
-
if (value
|
|
550
|
+
if (absent(value)) return undefined
|
|
534
551
|
if (typeof value === 'string') return value
|
|
535
552
|
const flags: string[] = []
|
|
536
553
|
const r = value
|
|
@@ -601,7 +618,7 @@ function coerceCanonical(
|
|
|
601
618
|
context: MetadataContext,
|
|
602
619
|
): string | undefined {
|
|
603
620
|
const canonical = value?.canonical
|
|
604
|
-
if (canonical
|
|
621
|
+
if (absent(canonical)) return undefined
|
|
605
622
|
if (typeof canonical === 'string') return resolveMetadataUrl(canonical, context)
|
|
606
623
|
if (canonical instanceof URL) return canonical.href
|
|
607
624
|
if (typeof canonical === 'object' && 'url' in canonical)
|
|
@@ -687,7 +704,7 @@ function joinUrlPath(...parts: string[]) {
|
|
|
687
704
|
|
|
688
705
|
// Normalizes every icon form (string | URL | object | group) into <link> descriptors.
|
|
689
706
|
function coerceIcons(value: Metadata['icons'], context: MetadataContext): MetadataLink[] {
|
|
690
|
-
if (value
|
|
707
|
+
if (absent(value)) return []
|
|
691
708
|
if (isIconGroups(value)) {
|
|
692
709
|
return [
|
|
693
710
|
...iconList(value.icon, 'icon', context),
|
|
@@ -706,11 +723,11 @@ function isIconGroups(value: NonNullable<Metadata['icons']>): value is MetadataI
|
|
|
706
723
|
}
|
|
707
724
|
|
|
708
725
|
function iconList(
|
|
709
|
-
value: MetadataIcon | MetadataIcon[] | undefined,
|
|
726
|
+
value: MetadataIcon | MetadataIcon[] | null | undefined,
|
|
710
727
|
rel: string,
|
|
711
728
|
context: MetadataContext,
|
|
712
729
|
): MetadataLink[] {
|
|
713
|
-
if (value
|
|
730
|
+
if (absent(value)) return []
|
|
714
731
|
const list = Array.isArray(value) ? value : [value]
|
|
715
732
|
return list.map((icon): MetadataLink => {
|
|
716
733
|
if (typeof icon === 'string') return { rel, url: resolveMetadataUrl(icon, context) ?? icon }
|
|
@@ -725,7 +742,7 @@ function iconList(
|
|
|
725
742
|
}
|
|
726
743
|
|
|
727
744
|
function authorLinks(value: Metadata['authors'], context: MetadataContext): MetadataLink[] {
|
|
728
|
-
if (value
|
|
745
|
+
if (absent(value)) return []
|
|
729
746
|
return (Array.isArray(value) ? value : [value])
|
|
730
747
|
.filter((author): author is MetadataAuthor => typeof author === 'object' && author !== null)
|
|
731
748
|
.flatMap(author => {
|
|
@@ -775,7 +792,7 @@ function alternateLinks(value: Metadata['alternates'], context: MetadataContext)
|
|
|
775
792
|
for (const [type, entries] of Object.entries(value.types ?? {})) {
|
|
776
793
|
const listEntries = Array.isArray(entries) ? entries : [entries]
|
|
777
794
|
for (const entry of listEntries) {
|
|
778
|
-
const record =
|
|
795
|
+
const record: { url: unknown; title?: string } =
|
|
779
796
|
typeof entry === 'object' && !(entry instanceof URL) && entry !== null && 'url' in entry
|
|
780
797
|
? entry
|
|
781
798
|
: { url: entry }
|
|
@@ -794,8 +811,8 @@ function appleStartupLinks(
|
|
|
794
811
|
value: Metadata['appleWebApp'],
|
|
795
812
|
context: MetadataContext,
|
|
796
813
|
): MetadataLink[] {
|
|
797
|
-
if (!value || typeof value !== 'object' || value.startupImage
|
|
798
|
-
const images =
|
|
814
|
+
if (!value || typeof value !== 'object' || absent(value.startupImage)) return []
|
|
815
|
+
const images = [value.startupImage].flat()
|
|
799
816
|
return images.map(image => {
|
|
800
817
|
if (typeof image === 'string' || image instanceof URL) {
|
|
801
818
|
return {
|
|
@@ -853,8 +870,10 @@ function appLinkTags(value: Metadata['appLinks']): [string, string][] {
|
|
|
853
870
|
if (!value) return []
|
|
854
871
|
const tags: [string, string][] = []
|
|
855
872
|
for (const [platform, entries] of Object.entries(value)) {
|
|
856
|
-
for (const [
|
|
857
|
-
|
|
873
|
+
for (const target of entries === undefined ? [] : [entries].flat()) {
|
|
874
|
+
for (const [name, item] of Object.entries(target)) {
|
|
875
|
+
if (item !== undefined) tags.push([`al:${platform}:${name}`, coerceString(item)])
|
|
876
|
+
}
|
|
858
877
|
}
|
|
859
878
|
}
|
|
860
879
|
return tags
|
|
@@ -890,11 +909,14 @@ function resolveSocialMetadata(meta: Metadata): {
|
|
|
890
909
|
images: ownImages ? twitter?.images : og.images,
|
|
891
910
|
}
|
|
892
911
|
}
|
|
893
|
-
return {
|
|
912
|
+
return {
|
|
913
|
+
openGraph: og ? inherited(og, meta) : undefined,
|
|
914
|
+
twitter: twitter ? inherited(twitter, meta) : undefined,
|
|
915
|
+
}
|
|
894
916
|
}
|
|
895
917
|
|
|
896
918
|
// A block that renders no title/description of its own falls back to the page's.
|
|
897
|
-
function inherited<T extends { title?: Metadata['title']; description?: string }>(
|
|
919
|
+
function inherited<T extends { title?: Metadata['title']; description?: string | null }>(
|
|
898
920
|
block: T,
|
|
899
921
|
meta: Metadata,
|
|
900
922
|
): T {
|
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
|