@pyreon/zero 0.11.7 → 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/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
+ }
@@ -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. */