@kudzujs/core 0.6.21 → 0.6.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.
- package/README.md +11 -3
- package/framework/build.mjs +65 -15
- package/framework/core.d.ts +36 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,18 +128,26 @@ Static trusted HTML can be rendered without a transform layer:
|
|
|
128
128
|
|
|
129
129
|
The HTML is intentionally not sanitized. Use only trusted or previously sanitized build-time content. Reactive raw HTML, children on the same element, void elements, and keyed-list raw HTML are rejected.
|
|
130
130
|
|
|
131
|
-
Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order.
|
|
131
|
+
Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. A source style entry reads CSS, optionally transforms it, writes its declared output, and links it without an `afterBuild` file pipeline. `publicDir` defaults to `public` and may point elsewhere. Global or page `metadata` may be an object or a function of `{ route, params, props }`, so route props can set document language and head resources before rendering:
|
|
132
132
|
|
|
133
133
|
```js
|
|
134
134
|
export default {
|
|
135
135
|
base: "/newsletter",
|
|
136
|
-
|
|
136
|
+
publicDir: "../public",
|
|
137
|
+
styles: [{
|
|
138
|
+
source: "../src/styles/global.css",
|
|
139
|
+
output: "/assets/styles.css",
|
|
140
|
+
transform: css => transformCss(css)
|
|
141
|
+
}],
|
|
142
|
+
metadata: ({ props }) => ({ lang: props.locale, manifest: "/manifest.json" }),
|
|
137
143
|
async afterBuild({ outDir, routes, plans, rewrites, base }) {
|
|
138
|
-
// Write
|
|
144
|
+
// Write host rewrites, RSS, sitemap, or other non-document artifacts.
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
147
|
```
|
|
142
148
|
|
|
149
|
+
The transform may return CSS text or an object with a `css` string, matching common CSS processor results. Page-exported `metadata` takes precedence over config metadata and may use the same function form.
|
|
150
|
+
|
|
143
151
|
Do not render `<link rel="stylesheet">` from page or component JSX. Kudzu rejects direct static body stylesheets with a source location and catches computed JSX stylesheet output during rendering. Trusted `dangerouslySetInnerHTML` remains unparsed and is responsible for its own resource tags.
|
|
144
152
|
|
|
145
153
|
## State Semantics
|
package/framework/build.mjs
CHANGED
|
@@ -20,6 +20,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
20
20
|
const config = await loadConfig()
|
|
21
21
|
const base = normalizeBase(config.base)
|
|
22
22
|
const configuredStyles = normalizeStyles(config.styles, base)
|
|
23
|
+
const publicDirectory = normalizePublicDirectory(config.publicDir)
|
|
23
24
|
const navigationGroups = normalizeNavigation(config.navigation)
|
|
24
25
|
const navigationRoutes = navigationGroups.flatMap(group => group.routes)
|
|
25
26
|
const navigationByRoute = new Map(navigationGroups.flatMap(group => group.routes.map(route => [route, group])))
|
|
@@ -39,7 +40,8 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
39
40
|
|
|
40
41
|
const projectFiles = await walk(sourceDirectory)
|
|
41
42
|
const sourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file)).sort()
|
|
42
|
-
const
|
|
43
|
+
const configuredStyleSources = new Set(configuredStyles.sources.map(style => style.source))
|
|
44
|
+
const cssFiles = projectFiles.filter(file => file.endsWith(".css") && !configuredStyleSources.has(file)).sort()
|
|
43
45
|
if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
|
|
44
46
|
const sourceFileSet = new Set(sourceFiles)
|
|
45
47
|
const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
|
|
@@ -74,7 +76,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
74
76
|
const renderedHandlerUrls = new Set()
|
|
75
77
|
const styleUrls = [...new Set([
|
|
76
78
|
...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
|
|
77
|
-
...configuredStyles
|
|
79
|
+
...configuredStyles.urls
|
|
78
80
|
])]
|
|
79
81
|
const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
|
|
80
82
|
|
|
@@ -101,6 +103,9 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
101
103
|
const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
|
|
102
104
|
const applicationRoute = `/${route}`
|
|
103
105
|
const routePath = withBase(base, `/${route}`)
|
|
106
|
+
const metadataContext = { route: routePath, params, props }
|
|
107
|
+
const configuredMetadata = await resolveDocumentMetadata(config.metadata, metadataContext, "kudzu.config metadata")
|
|
108
|
+
const pageMetadata = await resolveDocumentMetadata(module.metadata, metadataContext, `${relative(root, pageFile)} metadata`)
|
|
104
109
|
const navigationGroup = navigationByRoute.get(applicationRoute)
|
|
105
110
|
const navigable = Boolean(navigationGroup)
|
|
106
111
|
const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
|
|
@@ -123,7 +128,8 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
123
128
|
navigationGroup.routeRecords.push(routeRecord)
|
|
124
129
|
}
|
|
125
130
|
const result = await renderPage(module.default, {
|
|
126
|
-
...
|
|
131
|
+
...configuredMetadata,
|
|
132
|
+
...pageMetadata,
|
|
127
133
|
styles: styleUrls.length ? styleUrls : false,
|
|
128
134
|
base,
|
|
129
135
|
runtimeAsset: runtimePlaceholder,
|
|
@@ -175,7 +181,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
175
181
|
const emittedHandlerModules = handlerModules.filter(module => renderedHandlerUrls.has(assetPath(base, `assets/${module.path}`)))
|
|
176
182
|
const renderedEffects = new Set(plans.flatMap(plan => plan.effects.map(effect => `${effect.module}:${effect.handler}`)))
|
|
177
183
|
const renderedWorkerReferences = workerReferences.filter(reference => renderedEffects.has(`${reference.module}:${reference.handler}`))
|
|
178
|
-
if (renderedWorkerReferences.length && await exists(join(
|
|
184
|
+
if (renderedWorkerReferences.length && await exists(join(publicDirectory, "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
|
|
179
185
|
const workerAssets = await emitWorkers(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
|
|
180
186
|
for (const module of emittedHandlerModules) {
|
|
181
187
|
for (const reference of workerReferences) {
|
|
@@ -354,7 +360,18 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
354
360
|
await mkdir(dirname(output), { recursive: true })
|
|
355
361
|
await cp(file, output)
|
|
356
362
|
}
|
|
357
|
-
|
|
363
|
+
for (const style of configuredStyles.sources) {
|
|
364
|
+
let css = await readFile(style.source, "utf8")
|
|
365
|
+
if (style.transform) {
|
|
366
|
+
const result = await style.transform(css, { source: style.source, output: style.output })
|
|
367
|
+
css = typeof result === "string" ? result : result?.css
|
|
368
|
+
if (typeof css !== "string") throw new Error(`${style.label}.transform must return CSS text or an object with a css string`)
|
|
369
|
+
}
|
|
370
|
+
const output = join(outputDirectory, style.output.slice(1))
|
|
371
|
+
await mkdir(dirname(output), { recursive: true })
|
|
372
|
+
await writeFile(output, css)
|
|
373
|
+
}
|
|
374
|
+
if (await exists(publicDirectory)) await cp(publicDirectory, outputDirectory, { recursive: true })
|
|
358
375
|
if (config.afterBuild !== undefined) {
|
|
359
376
|
if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
|
|
360
377
|
await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites })
|
|
@@ -3897,16 +3914,49 @@ async function loadConfig() {
|
|
|
3897
3914
|
}
|
|
3898
3915
|
|
|
3899
3916
|
function normalizeStyles(value, base) {
|
|
3900
|
-
if (value === undefined) return []
|
|
3901
|
-
if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3917
|
+
if (value === undefined) return { urls: [], sources: [] }
|
|
3918
|
+
if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array")
|
|
3919
|
+
const urls = []
|
|
3920
|
+
const sources = []
|
|
3921
|
+
for (let index = 0; index < value.length; index++) {
|
|
3922
|
+
const style = value[index]
|
|
3923
|
+
const label = `kudzu.config styles[${index}]`
|
|
3924
|
+
if (typeof style === "string") {
|
|
3925
|
+
if (!style) throw new Error(`${label} must be a non-empty URL`)
|
|
3926
|
+
if (style.startsWith("//")) throw new Error(`${label} must be root-relative or an absolute HTTP URL`)
|
|
3927
|
+
if (style.startsWith("/")) {
|
|
3928
|
+
urls.push(withBase(base, style))
|
|
3929
|
+
continue
|
|
3930
|
+
}
|
|
3931
|
+
if (!/^https?:\/\//i.test(style)) throw new Error(`${label} must be root-relative or an absolute HTTP URL`)
|
|
3932
|
+
try { new URL(style) } catch { throw new Error(`${label} must be root-relative or an absolute HTTP URL`) }
|
|
3933
|
+
urls.push(style)
|
|
3934
|
+
continue
|
|
3935
|
+
}
|
|
3936
|
+
if (!isPlainRecord(style) || Object.keys(style).some(key => !["source", "output", "transform"].includes(key))) throw new Error(`${label} must be a URL or a source style object`)
|
|
3937
|
+
if (typeof style.source !== "string" || !style.source) throw new Error(`${label}.source must be a non-empty file path`)
|
|
3938
|
+
if (typeof style.output !== "string" || !style.output.startsWith("/") || style.output.startsWith("//") || /[%?#\\\0]/.test(style.output) || style.output.split("/").includes("..") || !style.output.endsWith(".css")) throw new Error(`${label}.output must be a root-relative .css path without query, hash, or traversal`)
|
|
3939
|
+
if (style.transform !== undefined && typeof style.transform !== "function") throw new Error(`${label}.transform must be a function`)
|
|
3940
|
+
const entry = { label, source: resolve(root, style.source), output: style.output, transform: style.transform }
|
|
3941
|
+
sources.push(entry)
|
|
3942
|
+
urls.push(withBase(base, style.output))
|
|
3943
|
+
}
|
|
3944
|
+
return { urls, sources }
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
function normalizePublicDirectory(value) {
|
|
3948
|
+
if (value === undefined) return join(root, "public")
|
|
3949
|
+
if (typeof value !== "string" || !value) throw new Error("kudzu.config publicDir must be a non-empty directory path")
|
|
3950
|
+
const directory = resolve(root, value)
|
|
3951
|
+
if (directory === outputDirectory || directory === workDirectory) throw new Error("kudzu.config publicDir cannot be dist or .kudzu")
|
|
3952
|
+
return directory
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
async function resolveDocumentMetadata(value, context, label) {
|
|
3956
|
+
if (value === undefined) return {}
|
|
3957
|
+
const metadata = typeof value === "function" ? await value(context) : value
|
|
3958
|
+
if (!isPlainRecord(metadata)) throw new Error(`${label} must be a plain object or a function returning one`)
|
|
3959
|
+
return metadata
|
|
3910
3960
|
}
|
|
3911
3961
|
|
|
3912
3962
|
export function normalizeNavigation(value) {
|
package/framework/core.d.ts
CHANGED
|
@@ -33,36 +33,44 @@ export function listExpression(read: () => unknown, module: string, handler: str
|
|
|
33
33
|
export function listItem(): unknown
|
|
34
34
|
export function listConditional(kind: "and" | "ternary", read: () => unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string): unknown
|
|
35
35
|
|
|
36
|
+
export type PageMetadata = {
|
|
37
|
+
title?: string
|
|
38
|
+
description?: string
|
|
39
|
+
lang?: string
|
|
40
|
+
locale?: string
|
|
41
|
+
siteName?: string
|
|
42
|
+
type?: string
|
|
43
|
+
url?: string
|
|
44
|
+
image?: string
|
|
45
|
+
imageAlt?: string
|
|
46
|
+
twitterCard?: string
|
|
47
|
+
twitterImage?: string
|
|
48
|
+
themeColor?: string
|
|
49
|
+
icon?: string
|
|
50
|
+
appleTouchIcon?: string
|
|
51
|
+
manifest?: string
|
|
52
|
+
styles?: boolean | string[]
|
|
53
|
+
base?: string
|
|
54
|
+
runtimeAsset?: string
|
|
55
|
+
effectAsset?: string
|
|
56
|
+
nativeAsset?: string
|
|
57
|
+
paramAsset?: string
|
|
58
|
+
runtimeParams?: string[]
|
|
59
|
+
navigationAsset?: string
|
|
60
|
+
applicationId?: string
|
|
61
|
+
layoutId?: string
|
|
62
|
+
routeId?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type MetadataContext<Props = Record<string, unknown>> = {
|
|
66
|
+
route: string
|
|
67
|
+
params: Record<string, string>
|
|
68
|
+
props: Props
|
|
69
|
+
}
|
|
70
|
+
|
|
36
71
|
export function renderPage<Props = Record<string, never>>(
|
|
37
72
|
component: (props: Props) => unknown | Promise<unknown>,
|
|
38
|
-
metadata?:
|
|
39
|
-
title?: string
|
|
40
|
-
description?: string
|
|
41
|
-
lang?: string
|
|
42
|
-
locale?: string
|
|
43
|
-
siteName?: string
|
|
44
|
-
type?: string
|
|
45
|
-
url?: string
|
|
46
|
-
image?: string
|
|
47
|
-
imageAlt?: string
|
|
48
|
-
twitterCard?: string
|
|
49
|
-
twitterImage?: string
|
|
50
|
-
themeColor?: string
|
|
51
|
-
icon?: string
|
|
52
|
-
appleTouchIcon?: string
|
|
53
|
-
manifest?: string
|
|
54
|
-
styles?: boolean | string[]
|
|
55
|
-
base?: string
|
|
56
|
-
runtimeAsset?: string
|
|
57
|
-
effectAsset?: string
|
|
58
|
-
nativeAsset?: string
|
|
59
|
-
paramAsset?: string
|
|
60
|
-
runtimeParams?: string[]
|
|
61
|
-
navigationAsset?: string
|
|
62
|
-
applicationId?: string
|
|
63
|
-
layoutId?: string
|
|
64
|
-
routeId?: string
|
|
65
|
-
},
|
|
73
|
+
metadata?: PageMetadata,
|
|
66
74
|
props?: Props,
|
|
67
75
|
layout?: (props: { children: unknown }) => unknown | Promise<unknown>
|
|
68
76
|
): Promise<{
|