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 的样式文件列表(相对项目根目录), - * 内容会被内联进渲染产物的 `