@wular/pnext 0.0.20 → 0.0.22

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.
@@ -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: string | undefined) => {
228
- if (content) tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(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: string | undefined) => {
239
+ const pushProperty = (property: string, content: MetaContent) => {
231
240
  if (content)
232
- tags.push(`<meta property="${escapeHtml(property)}" content="${escapeHtml(content)}"/>`)
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 !== undefined) {
267
- pushProperty('pinterest-rich-pin', meta.pinterest.richPin ? 'true' : 'false')
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: string | undefined) => {
292
+ const push = (property: string, content: MetaContent) => {
282
293
  if (content)
283
- tags.push(`<meta property="${escapeHtml(property)}" content="${escapeHtml(content)}"/>`)
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', og.url === undefined ? undefined : resolveMetadataUrl(og.url, context))
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: string | undefined) => {
317
- if (content) tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(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
- push('twitter:player', resolveMetadataUrl(twitter.players?.playerUrl, context))
338
- push('twitter:player:stream', resolveMetadataUrl(twitter.players?.streamUrl, context))
339
- push('twitter:player:width', coerceString(twitter.players?.width) || undefined)
340
- push('twitter:player:height', coerceString(twitter.players?.height) || undefined)
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 === undefined) return ''
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 === undefined) return undefined
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 === undefined) return []
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 === undefined) return undefined
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 === undefined) return undefined
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 === undefined) return []
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 === undefined) return []
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 === undefined) return []
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 === undefined) return []
798
- const images = Array.isArray(value.startupImage) ? value.startupImage : [value.startupImage]
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 [name, item] of Object.entries(entries)) {
857
- if (item !== undefined) tags.push([`al:${platform}:${name}`, String(item)])
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 { openGraph: og && inherited(og, meta), twitter: twitter && inherited(twitter, meta) }
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 {
@@ -4470,6 +4470,7 @@ export function errorLogTrace(error: Error): string {
4470
4470
  }
4471
4471
 
4472
4472
  function logDevError(options: RenderOptions, error: Error, clientSsrError = false) {
4473
+ if (error.name === 'DynamicServerError') return
4473
4474
  const seen = isBuildError(error) ? loggedBuildErrors : loggedRuntimeErrors
4474
4475
  if (seen.has(error)) return
4475
4476
  seen.add(error)
@@ -6733,7 +6734,7 @@ function anchorInlineSuspenseHoles(html: string): string {
6733
6734
  .replace(/<!--\/\$s:[^>]+?-->/g, '</pnext-hole>')
6734
6735
  }
6735
6736
 
6736
- function clientSuspenseRefreshScript(nonce: string | undefined) {
6737
+ export function clientSuspenseRefreshScript(nonce: string | undefined) {
6737
6738
  const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
6738
6739
  // Graft each streamed island's content over its hole. The hole is a `<pnext-hole>` element rather
6739
6740
  // than preact's `$s:ID` comment markers: hydration of an ancestor client layout removes comment
@@ -6873,7 +6874,7 @@ function streamPageSlotMode(options: RenderOptions): PageSlotMarkerMode {
6873
6874
  return pageSlotIsMountTarget(options) ? 'materialize' : 'keep'
6874
6875
  }
6875
6876
 
6876
- function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
6877
+ export function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
6877
6878
  // Pages that mount nothing on #pnext-page never need the wrapper div: the
6878
6879
  // page's children stay direct children of <body>, matching Next's document
6879
6880
  // structure (tests assert structural selectors like `body > p`). `keep`
@@ -11,6 +11,12 @@ import {
11
11
  } from './engine'
12
12
  import { onExtensionHostReset } from '../extensions'
13
13
 
14
+ // `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
15
+ // bun-types typecheck this file and a bare `Bun` identifier would fail them.
16
+ const { Bun: bun } = globalThis as unknown as {
17
+ Bun: { resolveSync(specifier: string, parent: string): string }
18
+ }
19
+
14
20
  // Conditions for "is this a local source file" lookups. Ordered to mirror the
15
21
  // preference a bundler applies, though `exports` key order ultimately decides.
16
22
  const localConditions = ['source', 'import', 'browser', 'node', 'default', 'require']
@@ -668,7 +674,7 @@ export function resolveRuntimeSpecifier(dir: string, specifier: string): string
668
674
  const cached = runtimeResolveCache.get(key)
669
675
  if (cached !== undefined) return cached
670
676
  try {
671
- const resolved = Bun.resolveSync(specifier, dir)
677
+ const resolved = bun.resolveSync(specifier, dir)
672
678
  runtimeResolveCache.set(key, resolved)
673
679
  return resolved
674
680
  } catch {
@@ -17,6 +17,12 @@ const parseSync: typeof import('oxc-parser').parseSync = (...args) =>
17
17
  import { loadNative } from '../utils/native-require'
18
18
  import { spliceSource } from '../runtime/module-transform'
19
19
 
20
+ // `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
21
+ // bun-types typecheck this file and a bare `Bun` identifier would fail them.
22
+ const { Bun: bun } = globalThis as unknown as {
23
+ Bun: { hash(input: string | ArrayBufferView): { toString(radix?: number): string } }
24
+ }
25
+
20
26
  export interface ScanEdge {
21
27
  specifier: string
22
28
  /** Bound names: `default`, `*` (namespace object), or the imported names. */
@@ -187,7 +193,7 @@ export function rewriteFacts(file: string, source: string): RewriteFacts {
187
193
  }
188
194
 
189
195
  function parsed(file: string, source: string, lang = langForFile(file)) {
190
- const key = `${lang}\0${source.length}\0${Bun.hash(source).toString()}`
196
+ const key = `${lang}\0${source.length}\0${bun.hash(source).toString()}`
191
197
  const cached = memo.get(key)
192
198
  if (cached) {
193
199
  hits += 1
@@ -3,10 +3,18 @@
3
3
  * always derives Host from the request URL and drops any supplied one. An app that forwards its
4
4
  * inbound request headers to an upstream - every proxy/BFF route handler - would otherwise send
5
5
  * the local Host upstream, an RFC 9110 7.2 violation that real upstreams reject. Match undici.
6
+ *
7
+ * Bun also ignores response-body cancellation: cancelling a fetch Response's stream leaves the
8
+ * upstream request in flight and its keep-alive socket checked out for good, so a passthrough proxy
9
+ * route whose client disconnects mid-stream leaks a connection per abort until later fetches to that
10
+ * host queue behind them forever. Only the fetch's own AbortSignal tears the request down, so each
11
+ * response body is tracked against its controller here and the serving layer fires it on disconnect.
6
12
  */
7
13
 
8
14
  let installed = false
9
15
 
16
+ const upstreamAborts = new WeakMap<ReadableStream<Uint8Array>, AbortController>()
17
+
10
18
  function isHostKey(key: unknown): boolean {
11
19
  return typeof key === 'string' && key.length === 4 && key.toLowerCase() === 'host'
12
20
  }
@@ -36,7 +44,43 @@ export function installFetchHostNormalization(): void {
36
44
  const original = globalThis.fetch.bind(globalThis)
37
45
  globalThis.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit) {
38
46
  const source = init?.headers ?? (input instanceof Request ? input.headers : undefined)
39
- if (source === undefined || !hasHost(source)) return original(input, init)
40
- return original(input, { ...init, headers: withoutHost(source) })
47
+ const normalized =
48
+ source !== undefined && hasHost(source) ? { ...init, headers: withoutHost(source) } : init
49
+ return trackUpstream(original, input, normalized)
41
50
  } as typeof globalThis.fetch
42
51
  }
52
+
53
+ /** Give every fetch an abort handle of its own, keyed on the body it streams back. */
54
+ function trackUpstream(
55
+ original: typeof globalThis.fetch,
56
+ input: RequestInfo | URL,
57
+ init: RequestInit | undefined,
58
+ ): Promise<Response> {
59
+ const controller = new AbortController()
60
+ const caller = init?.signal ?? (input instanceof Request ? input.signal : undefined)
61
+ const signal = caller ? AbortSignal.any([caller, controller.signal]) : controller.signal
62
+ return original(input, { ...init, signal }).then(response => {
63
+ if (response.body) upstreamAborts.set(response.body, controller)
64
+ return response
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Tear down the upstream fetch `body` is streaming from once the client goes away. Bun.serve never
70
+ * cancels the body it is writing, so this disconnect is the only chance to release that socket.
71
+ * Zero-cost for any response that is not a fetch passthrough.
72
+ */
73
+ export function abortUpstreamFetchOnDisconnect(
74
+ body: ReadableStream<Uint8Array> | null,
75
+ signal: AbortSignal | undefined,
76
+ ): void {
77
+ if (!body || !signal || !upstreamAborts.has(body)) return
78
+ const abort = () => {
79
+ const controller = upstreamAborts.get(body)
80
+ if (!controller) return
81
+ upstreamAborts.delete(body)
82
+ controller.abort()
83
+ }
84
+ if (signal.aborted) abort()
85
+ else signal.addEventListener('abort', abort, { once: true })
86
+ }
package/src/types.ts CHANGED
@@ -95,7 +95,8 @@ export interface ResourceHint extends Omit<MetadataLink, 'url'> {
95
95
  export type MetadataTitle =
96
96
  string | number | { absolute?: string; default?: string; template?: string | null }
97
97
 
98
- export type MetadataIcon = string | URL | (Partial<MetadataLink> & { url: string | URL })
98
+ export type MetadataIcon =
99
+ string | URL | (Omit<Partial<MetadataLink>, 'url'> & { url: string | URL })
99
100
 
100
101
  export interface MetadataIconGroups {
101
102
  icon?: MetadataIcon | MetadataIcon[]
@@ -116,7 +117,7 @@ export interface MetadataRobots {
116
117
  nosnippet?: boolean
117
118
  noimageindex?: boolean
118
119
  nocache?: boolean
119
- googleBot?: MetadataRobots & Record<string, string | number | boolean | undefined>
120
+ googleBot?: string | (MetadataRobots & Record<string, string | number | boolean | undefined>)
120
121
  }
121
122
 
122
123
  export type MetadataImage =
@@ -134,7 +135,7 @@ export type MetadataImage =
134
135
  export interface MetadataOpenGraph {
135
136
  title?: MetadataTitle
136
137
  description?: string
137
- url?: string | URL
138
+ url?: string | URL | null
138
139
  siteName?: string
139
140
  locale?: string
140
141
  type?: string
@@ -142,109 +143,125 @@ export interface MetadataOpenGraph {
142
143
  videos?: MetadataImage | MetadataImage[]
143
144
  audio?: MetadataImage | MetadataImage[]
144
145
  publishedTime?: string
145
- authors?: string | string[]
146
+ authors?: string | URL | (string | URL)[] | null
146
147
  emails?: string | string[]
147
148
  phoneNumbers?: string | string[]
148
149
  faxNumbers?: string | string[]
149
150
  }
150
151
 
152
+ export interface MetadataTwitterPlayer {
153
+ playerUrl?: string | URL
154
+ streamUrl?: string | URL
155
+ width?: string | number
156
+ height?: string | number
157
+ }
158
+
151
159
  export interface MetadataTwitter {
152
160
  card?: string
153
- site?: string
154
- siteId?: string
155
- creator?: string
156
- creatorId?: string
161
+ site?: string | null
162
+ siteId?: string | null
163
+ creator?: string | null
164
+ creatorId?: string | null
157
165
  title?: MetadataTitle
158
- description?: string
166
+ description?: string | null
159
167
  images?: MetadataImage | MetadataImage[]
160
- players?: {
161
- playerUrl?: string | URL
162
- streamUrl?: string | URL
163
- width?: string | number
164
- height?: string | number
165
- }
168
+ players?: MetadataTwitterPlayer | MetadataTwitterPlayer[]
166
169
  app?: {
167
170
  name?: string
168
- id?: Record<string, string | undefined>
171
+ id?: Record<string, MetadataValue | undefined>
169
172
  url?: Record<string, string | URL | undefined>
170
173
  }
171
174
  }
172
175
 
176
+ export type MetadataAlternate =
177
+ | string
178
+ | URL
179
+ | { url: string | URL; title?: string }
180
+ | (string | URL | { url: string | URL; title?: string })[]
181
+
173
182
  export interface MetadataAlternates {
174
- canonical?: string | URL | { url: string | URL }
175
- languages?: Record<string, string | URL>
176
- media?: Record<string, string | URL>
177
- types?: Record<
178
- string,
179
- | string
180
- | URL
181
- | { url: string | URL; title?: string }
182
- | (string | URL | { url: string | URL; title?: string })[]
183
- >
183
+ canonical?: string | URL | { url: string | URL; title?: string } | null
184
+ languages?: Record<string, MetadataAlternate | null | undefined>
185
+ media?: Record<string, MetadataAlternate | null | undefined>
186
+ types?: Record<string, MetadataAlternate | null | undefined>
184
187
  }
185
188
 
189
+ // Every field is nullable: a metadata object may explicitly null a field out to
190
+ // suppress an inherited value, and null reads the same as absent here.
186
191
  export interface Metadata {
187
- metadataBase?: string | URL
188
- title?: MetadataTitle
189
- description?: string
190
- lang?: string
191
- keywords?: string | number | (string | number)[]
192
- authors?: string | MetadataAuthor | (string | MetadataAuthor)[]
193
- generator?: string
194
- applicationName?: string
195
- referrer?: string
196
- robots?: string | MetadataRobots
197
- openGraph?: MetadataOpenGraph
198
- twitter?: MetadataTwitter
199
- alternates?: MetadataAlternates
192
+ metadataBase?: string | URL | null
193
+ title?: MetadataTitle | null
194
+ description?: string | null
195
+ lang?: string | null
196
+ keywords?: string | number | (string | number)[] | null
197
+ authors?: string | MetadataAuthor | (string | MetadataAuthor)[] | null
198
+ generator?: string | null
199
+ applicationName?: string | null
200
+ referrer?: string | null
201
+ robots?: string | MetadataRobots | null
202
+ openGraph?: MetadataOpenGraph | null
203
+ twitter?: MetadataTwitter | null
204
+ alternates?: MetadataAlternates | null
200
205
  pagination?: {
201
- previous?: string | URL
202
- next?: string | URL
203
- }
204
- icons?: MetadataIcon | MetadataIcon[] | MetadataIconGroups
205
- manifest?: string | URL
206
- creator?: string
207
- publisher?: string
206
+ previous?: string | URL | null
207
+ next?: string | URL | null
208
+ } | null
209
+ icons?: MetadataIcon | MetadataIcon[] | MetadataIconGroups | null
210
+ manifest?: string | URL | null
211
+ creator?: string | null
212
+ publisher?: string | null
208
213
  formatDetection?: {
209
- email?: boolean
210
- address?: boolean
211
- telephone?: boolean
212
- url?: boolean
213
- }
214
+ email?: boolean | null
215
+ address?: boolean | null
216
+ telephone?: boolean | null
217
+ url?: boolean | null
218
+ } | null
214
219
  verification?: {
215
- google?: string | string[]
216
- yahoo?: string | string[]
217
- yandex?: string | string[]
218
- other?: Record<string, string | string[]>
219
- }
220
+ google?: MetadataValue | MetadataValue[] | null
221
+ yahoo?: MetadataValue | MetadataValue[] | null
222
+ yandex?: MetadataValue | MetadataValue[] | null
223
+ other?: Record<string, MetadataValue | MetadataValue[]>
224
+ } | null
220
225
  itunes?: {
221
226
  appId?: string
222
- appArgument?: string
223
- }
227
+ appArgument?: string | null
228
+ } | null
224
229
  appleWebApp?:
225
230
  | boolean
226
231
  | {
227
232
  capable?: boolean
228
- title?: string
233
+ title?: string | null
229
234
  statusBarStyle?: string
230
235
  startupImage?:
231
236
  | string
232
237
  | URL
233
238
  | { url: string | URL; media?: string }
234
239
  | (string | URL | { url: string | URL; media?: string })[]
240
+ | null
235
241
  }
242
+ | null
236
243
  facebook?: {
237
244
  appId?: string
238
- admins?: string | string[]
239
- }
245
+ admins?: MetadataValue | MetadataValue[]
246
+ } | null
240
247
  pinterest?: {
241
- richPin?: boolean
242
- }
243
- appLinks?: Record<string, Record<string, string | boolean | undefined>>
244
- other?: Record<string, string | string[]>
248
+ richPin?: boolean | string | null
249
+ } | null
250
+ appLinks?: MetadataAppLinks | null
251
+ other?: Record<string, MetadataValue | MetadataValue[] | null>
245
252
  links?: MetadataLink[]
246
253
  }
247
254
 
255
+ /** A metadata value that renders to text; numbers are stringified. */
256
+ export type MetadataValue = string | number
257
+
258
+ export type MetadataAppLinkTarget = Record<string, MetadataValue | boolean | URL | undefined>
259
+
260
+ export type MetadataAppLinks = Record<
261
+ string,
262
+ MetadataAppLinkTarget | MetadataAppLinkTarget[] | undefined
263
+ >
264
+
248
265
  export type ThemeColor = string | { color: string; media?: string }
249
266
 
250
267
  export interface Viewport {
@@ -255,8 +272,8 @@ export interface Viewport {
255
272
  userScalable?: boolean
256
273
  viewportFit?: 'auto' | 'contain' | 'cover'
257
274
  interactiveWidget?: 'resizes-visual' | 'resizes-content' | 'overlays-content'
258
- themeColor?: ThemeColor | ThemeColor[]
259
- colorScheme?: string
275
+ themeColor?: ThemeColor | ThemeColor[] | null
276
+ colorScheme?: string | null
260
277
  }
261
278
 
262
279
  export interface NextRequestCookie {
@@ -0,0 +1,70 @@
1
+ // Which @wular/pnext a generated project depends on. `bunx @wular/pnext` reuses a cached copy of the
2
+ // package instead of re-resolving the dist-tag, so a stale laptop runs an old CLI — and an old CLI
3
+ // stamping its own version pins the new project to it. The range comes from the registry instead,
4
+ // with the running version as the offline fallback.
5
+
6
+ import { bold, cyan, dim } from './ansi'
7
+ import { pnextVersionRange } from './fs'
8
+
9
+ const REGISTRY_URL = 'https://registry.npmjs.org/@wular%2Fpnext'
10
+ // Short: create/migrate must not hang behind a slow or captive network.
11
+ const TIMEOUT_MS = 2000
12
+
13
+ async function fetchLatestPublishedVersion() {
14
+ const response = await fetch(REGISTRY_URL, {
15
+ signal: AbortSignal.timeout(TIMEOUT_MS),
16
+ // The abbreviated document — dist-tags plus versions, without the full metadata.
17
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
18
+ })
19
+ if (!response.ok) return undefined
20
+ const body = (await response.json()) as { 'dist-tags'?: { latest?: string } }
21
+ return body['dist-tags']?.latest
22
+ }
23
+
24
+ // Test seam: the create/migrate suites assert the stamped range and must never hit the network.
25
+ let fetchLatestVersion: () => Promise<string | undefined> = fetchLatestPublishedVersion
26
+
27
+ /** @internal Test-only. Returns a restore function. */
28
+ export function setLatestVersionFetcher(fetcher: () => Promise<string | undefined>) {
29
+ const previous = fetchLatestVersion
30
+ fetchLatestVersion = fetcher
31
+ return () => {
32
+ fetchLatestVersion = previous
33
+ }
34
+ }
35
+
36
+ /**
37
+ * '^' + the latest published @wular/pnext, for a project this CLI generates. Never throws and never
38
+ * blocks for long — any failure falls back to the running version. Warns when the CLI itself is
39
+ * stale, which is the symptom the user actually has to fix.
40
+ */
41
+ export async function latestPnextVersionRange(command: 'create' | 'migrate') {
42
+ const running = pnextVersionRange().slice(1)
43
+ const latest = await fetchLatestVersion().catch(() => undefined)
44
+ if (!latest) return `^${running}`
45
+ if (isNewer(latest, running)) warnStaleCli(command, running, latest)
46
+ return `^${latest}`
47
+ }
48
+
49
+ function warnStaleCli(command: string, running: string, latest: string) {
50
+ console.error(
51
+ `\n${bold('⚠ Your pnext CLI is out of date')} — running ${running}, latest is ${latest}.\n` +
52
+ dim(
53
+ ` bunx reused a cached copy. This project depends on ^${latest}, but ${command} ran with ${running}.\n`,
54
+ ) +
55
+ ` Re-run with: ${cyan(`bunx @wular/pnext@latest ${command}`)}\n`,
56
+ )
57
+ }
58
+
59
+ /** Numeric major.minor.patch only; anything unparseable counts as "not newer". */
60
+ function isNewer(candidate: string, current: string) {
61
+ const parts = (value: string) => value.split('-')[0]!.split('.').map(Number)
62
+ const [a, b] = [parts(candidate), parts(current)]
63
+ for (let index = 0; index < 3; index += 1) {
64
+ const left = a[index] ?? 0
65
+ const right = b[index] ?? 0
66
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return false
67
+ if (left !== right) return left > right
68
+ }
69
+ return false
70
+ }