@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 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. Stylesheets produced by another build step can be declared globally so Kudzu still emits them in every document `<head>`. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
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
- styles: ["/assets/generated.css"],
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 generated.css, host rewrites, RSS, sitemap, or other static artifacts.
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
@@ -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 cssFiles = projectFiles.filter(file => file.endsWith(".css")).sort()
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
- ...(module.metadata ?? {}),
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(root, "public", "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
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
- if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
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 of URLs")
3902
- return value.map((style, index) => {
3903
- if (typeof style !== "string" || !style) throw new Error(`kudzu.config styles[${index}] must be a non-empty URL`)
3904
- if (style.startsWith("//")) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
3905
- if (style.startsWith("/")) return withBase(base, style)
3906
- if (!/^https?:\/\//i.test(style)) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
3907
- try { new URL(style) } catch { throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`) }
3908
- return style
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) {
@@ -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<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.21",
3
+ "version": "0.6.22",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",