From 4bdf05564dc5dcfcc33ac115e46179c47d2e8ccf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=82=AB=E7=82=AB?=
<112480306+ikenxuan@users.noreply.github.com>
Date: Wed, 16 Sep 2026 12:19:48 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BE=9D=E8=B5=96?=
=?UTF-8?q?=E5=8C=85=E9=87=8C=E7=9A=84=20CSS=20=E4=B8=8E=E5=AD=97=E4=BD=93?=
=?UTF-8?q?=EF=BC=88=E5=90=AB=20pnpm=20=E7=9A=84=20.pnpm=20=E5=B8=83?=
=?UTF-8?q?=E5=B1=80=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CSS url() 的 / 开头引用按「CSS 同级目录(构建产物)→ assetsDir」依次解析,
依赖包带进来的字体(Vite 输出成 /assets/*)不再在 file:// 截图页静默回退
- CSS 资源改按 assetsInlineLimit 落地:小文件内联为 data URI,大字体转 file://,
不再无条件 base64 进每一份 HTML;顺带处理百分号编码文件名与目录引用
- dir.cssEntry / extraStylePaths 支持直接写包名,走 Node 解析(exports 映射、
pnpm 的 .pnpm 真实路径),不再依赖硬拼 node_modules/<包名>
- createTemplateRenderer 透传 config.extraStylePaths(此前该项在约定流程里静默失效)
- 独立运行包强制内联 CSS 资源:CSS 文本构建期烘死,file:// 绝对路径换机器即失效
---
AGENTS.md | 2 +-
packages/core/package.json | 2 +-
packages/core/src/build/standalone.ts | 8 +-
packages/core/src/config/loader.ts | 60 ++++++++-
packages/core/src/runtime/html-wrapper.ts | 123 +++++++++++++++---
.../core/src/runtime/template-renderer.ts | 4 +
packages/core/src/types/index.ts | 23 ++--
.../core/tests/build/build-templates.test.ts | 46 +++++++
packages/core/tests/config/loader.test.ts | 30 +++++
.../core/tests/runtime/html-wrapper.test.ts | 82 ++++++++++++
.../tests/runtime/template-renderer.test.ts | 12 ++
.../content/docs/advanced/custom-config.mdx | 8 +-
packages/docs/content/docs/api/config-cli.mdx | 11 +-
packages/docs/content/docs/build/building.mdx | 4 +
.../docs/content/docs/guide/conventions.mdx | 2 +-
packages/docs/content/docs/guide/styling.mdx | 38 ++++++
.../docs/troubleshooting/runtime-errors.mdx | 7 +-
17 files changed, 426 insertions(+), 36 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index c73b6ba..cce0237 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,7 +17,7 @@
## 下游模板约定
-下游开发者的模板都在 `ktr/template/` 文件夹里(App Router 式强约定:目录即路由,文件名固定),静态资源放 `ktr/public/`(dev server 当 publicDir 服务,构建时复制到产物 `assets/`;标记里 `/xxx` 引用恒等于 `
/xxx`,SSR 产出 HTML 时框架按 `html.assetsInlineLimit` 内联为 base64 或转为 `file://` 绝对路径,三态位置一致):
+下游开发者的模板都在 `ktr/template/` 文件夹里(App Router 式强约定:目录即路由,文件名固定),静态资源放 `ktr/public/`(dev server 当 publicDir 服务,构建时复制到产物 `assets/`;标记里和 CSS `url()` 里的 `/xxx` 引用恒等于 `/xxx`,SSR 产出 HTML 时框架按 `html.assetsInlineLimit` 内联为 base64 或转为 `file://` 绝对路径,三态位置一致;CSS `url()` 走同一阈值,构建期 Vite 输出的 `/assets/*`(依赖包字体等)按产物目录解析):
- `ktr/template/<板块>/<模板>/index.tsx`:模板组件,默认导出 `defineTemplate(...)`;只有这个固定写法会注册为 `<板块>/<模板>`,裸写的 `.tsx` 不注册。
- `ktr/template/<板块>/<模板>/mock.ts`:类型安全的 TS mock(固定文件名),会被自动导出,面板中只读。
diff --git a/packages/core/package.json b/packages/core/package.json
index dc196bf..4ff9ef6 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -116,5 +116,5 @@
"engines": {
"node": ">=18.0.0"
},
- "time": "2026-08-29 02:03:30"
+ "time": "2026-09-16 12:19:48"
}
diff --git a/packages/core/src/build/standalone.ts b/packages/core/src/build/standalone.ts
index 3cc7cd7..d470c8f 100644
--- a/packages/core/src/build/standalone.ts
+++ b/packages/core/src/build/standalone.ts
@@ -251,8 +251,14 @@ const typecheckStandaloneEntry = async (root: string, entryPath: string): Promis
}
}
+/**
+ * 独立运行包的 CSS 文本在构建期就烘进入口,必须完全自洽:
+ * 这里解析出的 file:// 绝对路径指向的是构建机(临时的 CSS 构建目录随后会被删掉),
+ * 换台机器渲染就全部失效,所以强制内联全部资源(不受 html.assetsInlineLimit 影响)。
+ * 大字体因此会进产物——体积敏感时用普通打包(ktrBuildPlugin)而不是独立运行包。
+ */
const inlineCss = (cssPath: string, extraStylePaths: string[]): string => {
- const wrapper = new HtmlWrapper({})
+ const wrapper = new HtmlWrapper({ assetsInlineLimit: () => true })
return [cssPath, ...extraStylePaths]
.map((filePath) => wrapper.loadInlineCss(filePath))
.filter(Boolean)
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
index f6c7da1..de01e65 100644
--- a/packages/core/src/config/loader.ts
+++ b/packages/core/src/config/loader.ts
@@ -1,4 +1,5 @@
import fs from 'node:fs'
+import { createRequire } from 'node:module'
import path from 'node:path'
import { defu } from 'defu'
@@ -78,6 +79,61 @@ const loadUserConfig = async (cwd: string, configFile?: string): Promise (path.isAbsolute(value) ? value : path.resolve(root, value))
+/**
+ * 把 `包名/子路径` 这类写法解析成真实文件路径。
+ * 走 Node 自己的解析规则,而不是硬拼 `node_modules/<包名>`:pnpm 只把直接依赖软链到根
+ * `node_modules`,真实文件在 `.pnpm/<包名>@<版本>/node_modules/<包名>` 下,
+ * 而且包一旦声明了 exports 映射,拼路径和真实解析结果也会分叉。
+ * @param root 项目根目录,解析起点。
+ * @param specifier 包名加子路径,如 `some-font-pkg/index.css`。
+ * @returns 文件绝对路径;解析不到时返回 undefined。
+ */
+const resolvePackageFile = (root: string, specifier: string): string | undefined => {
+ try {
+ // createRequire 只需要一个解析基准,package.json 本身可以不存在。
+ return createRequire(path.join(root, 'package.json')).resolve(specifier)
+ } catch {
+ // 包没给这个子路径声明 exports 时 require.resolve 会抛错,继续按目录结构找。
+ }
+
+ let dir = root
+ for (;;) {
+ const candidate = path.join(dir, 'node_modules', specifier)
+ // 只认文件:`extraStylePaths: ['some-pkg']` 这种写法不应该解析成包目录。
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
+ return candidate
+ }
+
+ const parent = path.dirname(dir)
+ if (parent === dir) {
+ return undefined
+ }
+ dir = parent
+ }
+}
+
+/**
+ * 解析样式文件路径(`dir.cssEntry` 与 `extraStylePaths`)。
+ * 先按项目根解析;文件不存在时再按包名解析,于是可以直接引用依赖包里的样式,
+ * 例如 `extraStylePaths: ['@lobehub/webfont-harmony-sans-sc/index-full.css']`。
+ * 都解析不到时保留项目根相对路径,让后续的「文件不存在」提示指向用户写的那个值。
+ * @param root 项目根目录。
+ * @param value 用户配置里的路径或包名。
+ * @returns 绝对路径。
+ */
+const resolveStylePath = (root: string, value: string): string => {
+ if (path.isAbsolute(value)) {
+ return value
+ }
+
+ const local = path.resolve(root, value)
+ if (fs.existsSync(local)) {
+ return local
+ }
+
+ return resolvePackageFile(root, value) ?? local
+}
+
/**
* 自动探测 CSS 入口,优先使用 template/style.css,再退回任意 CSS 文件。
* @param root 项目根目录。
@@ -87,7 +143,7 @@ const resolvePath = (root: string, value: string): string => (path.isAbsolute(va
*/
const detectCssEntry = (root: string, templatesDir: string, cssEntry?: string): string => {
if (cssEntry) {
- return resolvePath(root, cssEntry)
+ return resolveStylePath(root, cssEntry)
}
const commonEntry = path.join(templatesDir, 'style.css')
@@ -144,7 +200,7 @@ export const resolveConfig = async (options: ResolveConfigOptions = {}): Promise
const copyAssets = mergedDir.copyAssets ?? true
// 构建产物目录只服务内部约定(SSR HTML 默认落盘等);打包时的产物目录由构建插件跟随打包器 outDir。
const outDir = resolvePath(root, 'dist/template')
- const extraStylePaths = (merged.extraStylePaths ?? []).map((item) => resolvePath(root, item))
+ const extraStylePaths = (merged.extraStylePaths ?? []).map((item) => resolveStylePath(root, item))
const cssEntry = detectCssEntry(root, templateDir, mergedDir.cssEntry)
const standaloneOutDir = resolvePath(root, merged.standalone?.outDir ?? 'dist/ktr')
diff --git a/packages/core/src/runtime/html-wrapper.ts b/packages/core/src/runtime/html-wrapper.ts
index 0b213fb..4976fca 100644
--- a/packages/core/src/runtime/html-wrapper.ts
+++ b/packages/core/src/runtime/html-wrapper.ts
@@ -38,6 +38,24 @@ body {
/** 转义双引号,让值可以安全嵌入 HTML 属性。 */
const attr = (value: string): string => value.replace(/"/g, '"')
+/**
+ * CSS 里的 url() 引用。值里不含引号和右括号,因此 image-set() 这类外层函数里的 url() 也能逐个命中。
+ * 与旧实现保持同一限制:`url("a)b.png")` 这种值里带右括号的写法不支持。
+ */
+const cssUrlPattern = /url\(\s*(['"]?)([^'")]*)\1\s*\)/g
+
+/**
+ * url() 的值是否由浏览器自己处理,不需要框架解析成本地文件:
+ * - 带协议的(`data:`、`http(s):`、`blob:`、Windows 盘符 `C:`);
+ * - 协议相对的(`//cdn.example.com/a.png`);
+ * - 纯片段(`#gradient`);
+ * - CSS 函数(`var(--logo)`、`image()`),它们不是路径。
+ * @param value url() 里的原始值(已 trim)。
+ * @returns 保持原样时返回 true。
+ */
+const isExternalCssReference = (value: string): boolean =>
+ value.startsWith('//') || value.startsWith('#') || value.includes('(') || /^[a-z][a-z0-9+.-]*:/i.test(value)
+
/**
* 把显式提供的主题字段转换成 CSS 变量;未提供的字段一律不输出,让组件库自身主题生效。
* @param theme 调用方显式提供的主题变量,可能为空或部分字段。
@@ -215,7 +233,17 @@ export class HtmlWrapper {
}
/**
- * 内联 CSS,并把相对资源路径转成 data URI,保证生产环境文件自洽。
+ * 内联 CSS,并把资源引用解析成本地可用的形式。
+ *
+ * 两类引用都会解析,依赖包(含 pnpm 的 `.pnpm` 真实路径)里的 CSS 与字体一视同仁——
+ * 解析只依赖 CSS 文件自身的位置,不假设任何 node_modules 布局:
+ * - 相对路径(`./x`、`../x`):相对 CSS 所在目录。构建时 Vite 把 CSS 引用的资源输出到产物目录,
+ * 依赖包里的 CSS 被 @import 内联后,它的字体引用也已经被改写成产物内的相对/根路径;
+ * - `/` 开头:先按 CSS 所在目录解析(构建产物里的 `/assets/*` 就是这一种),
+ * 再退到 assetsDir(`ktr/public` 里的资源按 `/xxx` 引用,与标记资源同一套约定)。
+ *
+ * 两种都按 assetsInlineLimit 决定形态,与标记资源一致:不超过阈值内联为 data URI(HTML 自洽),
+ * 超过的转成 file:// 绝对路径——字体这类大文件不再 base64 进每一份 HTML。
* @param cssFilePath 待内联的 CSS 文件路径。
* @returns 处理后的 CSS 文本;文件缺失时返回空字符串。
*/
@@ -225,24 +253,89 @@ export class HtmlWrapper {
return ''
}
- const cssDir = path.dirname(cssFilePath)
const cssContent = fs.readFileSync(cssFilePath, 'utf-8')
+ return cssContent.replace(cssUrlPattern, (match, quote: string, rawValue: string) => {
+ const rewritten = this.resolveCssReference(cssFilePath, rawValue)
+ return rewritten === undefined ? match : `url(${quote}${rewritten}${quote})`
+ })
+ }
- // 只重写相对路径的 url(),data:、http(s):、file:、# 和绝对路径保持原样。
- return cssContent.replace(
- /url\(\s*(['"]?)(?!data:|https?:|file:|#|\/)([^'")]+)\1\s*\)/g,
- (_match, quote: string, rawAssetPath: string) => {
- const normalizedAssetPath = rawAssetPath.trim()
- const assetFilePath = normalizedAssetPath.split(/[?#]/)[0]
- if (!assetFilePath) {
- return `url(${quote}${normalizedAssetPath}${quote})`
- }
+ /**
+ * 把单个 url() 的值解析成 data URI 或 file:// 绝对路径。
+ * 外部引用、CSS 函数和找不到的文件保持原样(后者打一条警告,方便定位写错的路径)。
+ * @param cssFilePath CSS 文件绝对路径,相对引用以它为基准。
+ * @param rawValue url() 里的原始值。
+ * @returns 改写后的 URL;无需改写时返回 undefined。
+ */
+ private resolveCssReference(cssFilePath: string, rawValue: string): string | undefined {
+ const value = rawValue.trim()
+ if (!value || isExternalCssReference(value)) {
+ return undefined
+ }
+
+ // 查询串和哈希只影响浏览器缓存,定位文件时去掉;改写后的 data URI / file:// 不再带它们
+ //(file:// 带查询串在部分平台直接解析不到文件)。
+ const reference = value.split(/[?#]/)[0]
+ if (!reference) {
+ return undefined
+ }
- const absoluteAssetPath = path.resolve(cssDir, assetFilePath)
- const dataUri = this.toDataUri(absoluteAssetPath)
- return dataUri ? `url(${quote}${dataUri}${quote})` : `url(${quote}${normalizedAssetPath}${quote})`
+ const assetPath = this.findCssAsset(cssFilePath, reference)
+ if (!assetPath) {
+ console.warn(`[ktr] CSS 引用的资源不存在,保持原路径:${value}`)
+ return undefined
+ }
+
+ if (this.shouldInlineAsset(assetPath)) {
+ return this.toDataUri(assetPath) ?? undefined
+ }
+
+ return pathToFileURL(assetPath).href
+ }
+
+ /**
+ * 按约定定位 CSS 引用的本地文件。
+ * `/` 开头的引用两种解释都试:构建产物根(CSS 同级,Vite 输出的 `assets/*`)和 assetsDir
+ * (随包发布的资源目录,`ktr/public` 里的文件按 `/xxx` 引用),命中即用。
+ * @param cssFilePath CSS 文件绝对路径。
+ * @param reference url() 里的引用路径(已去掉查询串与哈希)。
+ * @returns 命中的文件绝对路径;都找不到时返回 undefined。
+ */
+ private findCssAsset(cssFilePath: string, reference: string): string | undefined {
+ const rooted = reference.startsWith('/')
+ // `/` 开头时两个基准都试:CSS 同级(构建产物根)和 assetsDir(随包发布的资源目录)。
+ const bases = rooted ? [path.dirname(cssFilePath), this.assetsDir] : [path.dirname(cssFilePath)]
+
+ for (const base of bases) {
+ if (!base) {
+ continue
}
- )
+
+ for (const item of this.cssReferenceCandidates(reference)) {
+ const candidate = rooted ? path.join(base, item) : path.resolve(base, item)
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
+ return candidate
+ }
+ }
+ }
+
+ return undefined
+ }
+
+ /**
+ * url() 的值的候选写法:CSS 里的 URL 可能对空格等字符做了百分号编码
+ * (Vite 输出的资源地址就会编码,磁盘上的文件名是原文),两种都试一次。
+ * @param reference url() 里的引用路径。
+ * @returns 去重后的候选路径列表。
+ */
+ private cssReferenceCandidates(reference: string): string[] {
+ try {
+ const decoded = decodeURIComponent(reference)
+ return decoded === reference ? [reference] : [reference, decoded]
+ } catch {
+ // 非法百分号序列(如文件名里真的有 % )按原文找。
+ return [reference]
+ }
}
/**
diff --git a/packages/core/src/runtime/template-renderer.ts b/packages/core/src/runtime/template-renderer.ts
index 37a831d..c73dd4e 100644
--- a/packages/core/src/runtime/template-renderer.ts
+++ b/packages/core/src/runtime/template-renderer.ts
@@ -190,6 +190,10 @@ export const createTemplateRenderer = (callerUrl: string, options?: TemplateRend
// 标记资源根目录:开发态是 dir.assets 源码目录,生产态是产物里的 assets/,与 chunk 位置无关。
...(assetsDir ? { assetsDir } : {}),
assetsInlineLimit: config.html.assetsInlineLimit,
+ // karin.template.ts 里配置的额外样式(字体包 CSS 等)在这里注入,否则配置项只在手动
+ // createRenderer 时生效。生产 bundle 走 skipUserConfig,拿到的是默认空数组——发布产物
+ // 里没有配置文件,需要额外样式时由下游胶水层经 options.renderer 显式传入。
+ extraStylePaths: config.extraStylePaths,
...(ssrRuntime ? { ssrRuntime } : {}),
outputDir: path.join(config.outDir, 'html'),
captureDir: config.templateDir,
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 1fe32e1..70250af 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -164,9 +164,12 @@ export interface HtmlWrapperOptions {
*/
assetsDir?: string
/**
- * 标记资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:
+ * 资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:
* 不超过阈值的资源改写为 base64 data URI,超过的改写为 file:// 绝对路径;
* 函数形式按文件自行决定是否内联。
+ *
+ * 同时作用于标记资源(`
`)和 CSS 里 `url()` 引用的资源
+ * (含依赖包 CSS 带进来的字体),大字体因此不会 base64 进每一份 HTML。
* @default 4096
*/
assetsInlineLimit?: number | ((filePath: string) => boolean)
@@ -201,8 +204,9 @@ export interface RendererOptions {
*/
assetsDir?: string
/**
- * 标记资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:
- * 不超过阈值的内联为 base64 data URI,超过的转为 file:// 绝对路径;函数形式按文件决定。
+ * 资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:
+ * 标记资源和 CSS `url()` 引用的资源都按它决定形态——不超过阈值的内联为 base64 data URI,
+ * 超过的转为 file:// 绝对路径;函数形式按文件决定。大字体走 file://,不会 base64 进每份 HTML。
* @default 4096
*/
assetsInlineLimit?: number | ((filePath: string) => boolean)
@@ -304,8 +308,10 @@ export interface KtrConfig {
*/
standalone?: KtrStandaloneConfig
/**
- * 额外注入 SSR HTML 的样式文件列表(相对项目根目录),
- * 内容会被内联进渲染产物的 `