@pyreon/zero 0.11.8 → 0.11.9
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/lib/fs-router-BkbIWqek.js.map +1 -1
- package/lib/{fs-router-n4VA4lxu.js → fs-router-Dil4IKZR.js} +23 -19
- package/lib/fs-router-Dil4IKZR.js.map +1 -0
- package/lib/index.js +872 -17
- package/lib/index.js.map +1 -1
- package/lib/link.js +12 -1
- package/lib/link.js.map +1 -1
- package/package.json +10 -10
- package/src/entry-server.ts +124 -76
- package/src/favicon.ts +380 -0
- package/src/fs-router.ts +54 -13
- package/src/i18n-routing.ts +299 -0
- package/src/index.ts +125 -76
- package/src/link.tsx +12 -0
- package/src/meta.tsx +210 -0
- package/src/middleware.ts +65 -0
- package/src/not-found.ts +44 -0
- package/src/types.ts +2 -0
- package/src/vite-plugin.ts +258 -127
- package/lib/fs-router-n4VA4lxu.js.map +0 -1
package/src/meta.tsx
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { VNodeChild } from '@pyreon/core'
|
|
2
|
+
import { useHead } from '@pyreon/head'
|
|
3
|
+
import type { I18nRoutingConfig } from './i18n-routing'
|
|
4
|
+
import { extractLocaleFromPath } from './i18n-routing'
|
|
5
|
+
|
|
6
|
+
// ─── Meta component ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface MetaProps {
|
|
9
|
+
/** Page title. Accepts reactive accessor `() => string`. */
|
|
10
|
+
title?: string | (() => string)
|
|
11
|
+
/** Page description. Accepts reactive accessor. */
|
|
12
|
+
description?: string | (() => string)
|
|
13
|
+
/** Canonical URL. Also sets og:url. */
|
|
14
|
+
canonical?: string
|
|
15
|
+
/** Open Graph image URL. Also sets twitter:image. */
|
|
16
|
+
image?: string
|
|
17
|
+
/** Image alt text for accessibility. */
|
|
18
|
+
imageAlt?: string
|
|
19
|
+
/** Open Graph type. Default: "website" */
|
|
20
|
+
type?: 'website' | 'article' | 'product' | 'profile'
|
|
21
|
+
/** Site name for og:site_name. */
|
|
22
|
+
siteName?: string
|
|
23
|
+
/** Twitter card type. Default: "summary_large_image" */
|
|
24
|
+
twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player'
|
|
25
|
+
/** Twitter @handle. */
|
|
26
|
+
twitterSite?: string
|
|
27
|
+
/** Twitter creator @handle. */
|
|
28
|
+
twitterCreator?: string
|
|
29
|
+
/** Locale. Default: "en_US" */
|
|
30
|
+
locale?: string
|
|
31
|
+
/** Alternate locales for hreflang. */
|
|
32
|
+
alternateLocales?: Array<{ locale: string; url: string }>
|
|
33
|
+
/** Robots directives. Default: "index, follow" */
|
|
34
|
+
robots?: string
|
|
35
|
+
/** Published time (ISO 8601) for article type. */
|
|
36
|
+
publishedTime?: string
|
|
37
|
+
/** Modified time (ISO 8601) for article type. */
|
|
38
|
+
modifiedTime?: string
|
|
39
|
+
/** Article author. */
|
|
40
|
+
author?: string
|
|
41
|
+
/** Article tags. */
|
|
42
|
+
tags?: string[]
|
|
43
|
+
/** JSON-LD structured data object. */
|
|
44
|
+
jsonLd?: Record<string, unknown>
|
|
45
|
+
/** Additional custom meta tags. */
|
|
46
|
+
extra?: Array<{ name?: string; property?: string; content: string }>
|
|
47
|
+
/**
|
|
48
|
+
* I18n routing config — when provided, auto-generates hreflang alternate
|
|
49
|
+
* links for all locales based on the current path.
|
|
50
|
+
* Also sets og:locale and og:locale:alternate.
|
|
51
|
+
*/
|
|
52
|
+
i18n?: I18nRoutingConfig
|
|
53
|
+
/** Base URL for building absolute hreflang URLs. e.g. "https://example.com" */
|
|
54
|
+
origin?: string
|
|
55
|
+
children?: VNodeChild
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resolveStr = (v: string | (() => string) | undefined): string | undefined =>
|
|
59
|
+
typeof v === 'function' ? v() : v
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Declarative meta component for SSR-compatible page metadata.
|
|
63
|
+
*
|
|
64
|
+
* Supports reactive title/description — when passed as `() => string` accessors,
|
|
65
|
+
* they are forwarded to `useHead()` as a reactive getter so updates propagate
|
|
66
|
+
* automatically via signal tracking.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```tsx
|
|
70
|
+
* <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @example Reactive title
|
|
74
|
+
* ```tsx
|
|
75
|
+
* const count = signal(0)
|
|
76
|
+
* <Meta title={() => `${count()} items`} />
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function Meta(props: MetaProps): VNodeChild {
|
|
80
|
+
const hasReactiveTitle = typeof props.title === 'function'
|
|
81
|
+
const hasReactiveDescription = typeof props.description === 'function'
|
|
82
|
+
|
|
83
|
+
// If title or description are reactive accessors, pass a getter to useHead
|
|
84
|
+
// so it re-evaluates when the signals change.
|
|
85
|
+
if (hasReactiveTitle || hasReactiveDescription) {
|
|
86
|
+
useHead((() => {
|
|
87
|
+
const title = resolveStr(props.title)
|
|
88
|
+
const description = resolveStr(props.description)
|
|
89
|
+
const tags = buildMetaTags({ ...props, title, description } as any)
|
|
90
|
+
return { title, meta: tags.meta, link: tags.link, script: tags.script }
|
|
91
|
+
}) as any)
|
|
92
|
+
} else {
|
|
93
|
+
const title = resolveStr(props.title)
|
|
94
|
+
const description = resolveStr(props.description)
|
|
95
|
+
const tags = buildMetaTags({ ...props, title, description } as any)
|
|
96
|
+
useHead({ title, meta: tags.meta, link: tags.link, script: tags.script } as any)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return props.children ?? null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface MetaTags {
|
|
103
|
+
meta: Array<Record<string, string>>
|
|
104
|
+
link: Array<Record<string, string>>
|
|
105
|
+
script: Array<{ type: string; children: string }>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildMetaTags(
|
|
109
|
+
props: Omit<MetaProps, 'title' | 'description' | 'children'> & {
|
|
110
|
+
title?: string
|
|
111
|
+
description?: string
|
|
112
|
+
},
|
|
113
|
+
): MetaTags {
|
|
114
|
+
const meta: Array<Record<string, string>> = []
|
|
115
|
+
const link: Array<Record<string, string>> = []
|
|
116
|
+
const script: Array<{ type: string; children: string }> = []
|
|
117
|
+
|
|
118
|
+
const {
|
|
119
|
+
title, description, canonical, image, imageAlt,
|
|
120
|
+
type = 'website', siteName,
|
|
121
|
+
twitterCard = 'summary_large_image', twitterSite, twitterCreator,
|
|
122
|
+
locale = 'en_US', alternateLocales,
|
|
123
|
+
robots = 'index, follow',
|
|
124
|
+
publishedTime, modifiedTime, author, tags, jsonLd, extra,
|
|
125
|
+
} = props
|
|
126
|
+
|
|
127
|
+
if (description) meta.push({ name: 'description', content: description })
|
|
128
|
+
if (robots) meta.push({ name: 'robots', content: robots })
|
|
129
|
+
if (author) meta.push({ name: 'author', content: author })
|
|
130
|
+
|
|
131
|
+
if (title) meta.push({ property: 'og:title', content: title })
|
|
132
|
+
if (description) meta.push({ property: 'og:description', content: description })
|
|
133
|
+
if (canonical) meta.push({ property: 'og:url', content: canonical })
|
|
134
|
+
if (image) meta.push({ property: 'og:image', content: image })
|
|
135
|
+
if (imageAlt) meta.push({ property: 'og:image:alt', content: imageAlt })
|
|
136
|
+
meta.push({ property: 'og:type', content: type })
|
|
137
|
+
if (siteName) meta.push({ property: 'og:site_name', content: siteName })
|
|
138
|
+
meta.push({ property: 'og:locale', content: locale })
|
|
139
|
+
|
|
140
|
+
if (type === 'article') {
|
|
141
|
+
if (publishedTime) meta.push({ property: 'article:published_time', content: publishedTime })
|
|
142
|
+
if (modifiedTime) meta.push({ property: 'article:modified_time', content: modifiedTime })
|
|
143
|
+
if (author) meta.push({ property: 'article:author', content: author })
|
|
144
|
+
if (tags) for (const tag of tags) meta.push({ property: 'article:tag', content: tag })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
meta.push({ name: 'twitter:card', content: twitterCard })
|
|
148
|
+
if (title) meta.push({ name: 'twitter:title', content: title })
|
|
149
|
+
if (description) meta.push({ name: 'twitter:description', content: description })
|
|
150
|
+
if (image) meta.push({ name: 'twitter:image', content: image })
|
|
151
|
+
if (imageAlt) meta.push({ name: 'twitter:image:alt', content: imageAlt })
|
|
152
|
+
if (twitterSite) meta.push({ name: 'twitter:site', content: twitterSite })
|
|
153
|
+
if (twitterCreator) meta.push({ name: 'twitter:creator', content: twitterCreator })
|
|
154
|
+
|
|
155
|
+
if (canonical) link.push({ rel: 'canonical', href: canonical })
|
|
156
|
+
if (alternateLocales) {
|
|
157
|
+
for (const alt of alternateLocales) {
|
|
158
|
+
link.push({ rel: 'alternate', hreflang: alt.locale, href: alt.url })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (jsonLd) {
|
|
163
|
+
script.push({
|
|
164
|
+
type: 'application/ld+json',
|
|
165
|
+
children: JSON.stringify({ '@context': 'https://schema.org', ...jsonLd }),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (extra) for (const tag of extra) meta.push(tag)
|
|
170
|
+
|
|
171
|
+
// I18n: auto-generate hreflang alternates from i18nRouting config
|
|
172
|
+
if (props.i18n) {
|
|
173
|
+
const i18nConfig = props.i18n
|
|
174
|
+
const origin = props.origin ?? ''
|
|
175
|
+
const currentPath = canonical?.replace(origin, '') ?? '/'
|
|
176
|
+
const { pathWithoutLocale } = extractLocaleFromPath(
|
|
177
|
+
currentPath,
|
|
178
|
+
i18nConfig.locales,
|
|
179
|
+
i18nConfig.defaultLocale,
|
|
180
|
+
)
|
|
181
|
+
const strategy = i18nConfig.strategy ?? 'prefix-except-default'
|
|
182
|
+
|
|
183
|
+
for (const loc of i18nConfig.locales) {
|
|
184
|
+
const localizedPath =
|
|
185
|
+
strategy === 'prefix-except-default' && loc === i18nConfig.defaultLocale
|
|
186
|
+
? pathWithoutLocale
|
|
187
|
+
: `/${loc}${pathWithoutLocale === '/' ? '' : pathWithoutLocale}`
|
|
188
|
+
|
|
189
|
+
link.push({
|
|
190
|
+
rel: 'alternate',
|
|
191
|
+
hreflang: loc,
|
|
192
|
+
href: `${origin}${localizedPath}`,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// og:locale:alternate for non-current locales
|
|
196
|
+
if (loc !== locale) {
|
|
197
|
+
meta.push({ property: 'og:locale:alternate', content: loc })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// x-default hreflang pointing to default locale
|
|
202
|
+
link.push({
|
|
203
|
+
rel: 'alternate',
|
|
204
|
+
hreflang: 'x-default',
|
|
205
|
+
href: `${origin}${pathWithoutLocale}`,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { meta, link, script }
|
|
210
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
|
|
2
|
+
|
|
3
|
+
// ─── Middleware composition ─────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Chains multiple middleware functions into a single middleware.
|
|
6
|
+
// Each middleware runs in order. If any returns a Response, the chain
|
|
7
|
+
// short-circuits and that Response is returned. If all return void,
|
|
8
|
+
// the composed middleware returns void (continues to rendering).
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compose multiple middleware into a single middleware function.
|
|
12
|
+
* Middleware runs sequentially — if any returns a Response, the chain stops.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { compose } from "@pyreon/zero/middleware"
|
|
16
|
+
* import { corsMiddleware } from "@pyreon/zero/cors"
|
|
17
|
+
* import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
|
|
18
|
+
*
|
|
19
|
+
* const combined = compose(
|
|
20
|
+
* corsMiddleware({ origin: "*" }),
|
|
21
|
+
* rateLimitMiddleware({ max: 100 }),
|
|
22
|
+
* cacheMiddleware(),
|
|
23
|
+
* )
|
|
24
|
+
*/
|
|
25
|
+
export function compose(...middlewares: Middleware[]): Middleware {
|
|
26
|
+
return async (ctx: MiddlewareContext) => {
|
|
27
|
+
for (const mw of middlewares) {
|
|
28
|
+
const result = await mw(ctx)
|
|
29
|
+
if (result instanceof Response) return result
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Shared request context ─────────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// Lightweight context bag attached to MiddlewareContext.locals so middleware
|
|
37
|
+
// can communicate without coupling. Uses a namespaced key to avoid collisions
|
|
38
|
+
// with user-defined locals.
|
|
39
|
+
|
|
40
|
+
const ZERO_CTX_KEY = '__zeroCtx'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the shared Zero context from a middleware context.
|
|
44
|
+
* Creates one if it doesn't exist. Middleware can use this to
|
|
45
|
+
* pass data to downstream middleware without polluting `ctx.locals`.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* const authMiddleware: Middleware = (ctx) => {
|
|
49
|
+
* const zctx = getContext(ctx)
|
|
50
|
+
* zctx.userId = "user_123"
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* const loggingMiddleware: Middleware = (ctx) => {
|
|
54
|
+
* const zctx = getContext(ctx)
|
|
55
|
+
* console.log("User:", zctx.userId)
|
|
56
|
+
* }
|
|
57
|
+
*/
|
|
58
|
+
export function getContext(ctx: MiddlewareContext): Record<string, unknown> {
|
|
59
|
+
let zctx = ctx.locals[ZERO_CTX_KEY] as Record<string, unknown> | undefined
|
|
60
|
+
if (!zctx) {
|
|
61
|
+
zctx = {}
|
|
62
|
+
ctx.locals[ZERO_CTX_KEY] = zctx
|
|
63
|
+
}
|
|
64
|
+
return zctx
|
|
65
|
+
}
|
package/src/not-found.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ComponentFn } from "@pyreon/core";
|
|
2
|
+
import { h } from "@pyreon/core";
|
|
3
|
+
import { renderToString } from "@pyreon/runtime-server";
|
|
4
|
+
|
|
5
|
+
// ─── 404 Not Found rendering ────────────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// Shared utility for rendering 404 pages in both dev (vite-plugin) and
|
|
8
|
+
// production (entry-server). Renders the notFoundComponent into HTML
|
|
9
|
+
// and wraps it in a minimal document if no template is provided.
|
|
10
|
+
|
|
11
|
+
const DEFAULT_404_BODY =
|
|
12
|
+
"<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a 404 component to a full HTML string.
|
|
16
|
+
* If no component is provided, returns a default 404 page.
|
|
17
|
+
*/
|
|
18
|
+
export async function render404Page(
|
|
19
|
+
component: ComponentFn | undefined,
|
|
20
|
+
template?: string,
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
let body: string;
|
|
23
|
+
if (component) {
|
|
24
|
+
body = await renderToString(h(component, null));
|
|
25
|
+
} else {
|
|
26
|
+
body = DEFAULT_404_BODY;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (template?.includes("<!--pyreon-app-->")) {
|
|
30
|
+
return template.replace("<!--pyreon-app-->", body);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `<!DOCTYPE html>
|
|
34
|
+
<html lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="UTF-8">
|
|
37
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
38
|
+
<title>404 — Not Found</title>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
${body}
|
|
42
|
+
</body>
|
|
43
|
+
</html>`;
|
|
44
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface FileRoute {
|
|
|
105
105
|
isError: boolean
|
|
106
106
|
/** Whether this is a loading fallback file. */
|
|
107
107
|
isLoading: boolean
|
|
108
|
+
/** Whether this is a not-found (404) file. */
|
|
109
|
+
isNotFound: boolean
|
|
108
110
|
/** Whether this is a catch-all route. */
|
|
109
111
|
isCatchAll: boolean
|
|
110
112
|
/** Resolved rendering mode. */
|