Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

## 下游模板约定

下游开发者的模板都在 `ktr/template/` 文件夹里(App Router 式强约定:目录即路由,文件名固定),静态资源放 `ktr/public/`(dev server 当 publicDir 服务,构建时复制到产物 `assets/`;标记里 `/xxx` 引用恒等于 `<dir.assets>/xxx`,SSR 产出 HTML 时框架按 `html.assetsInlineLimit` 内联为 base64 或转为 `file://` 绝对路径,三态位置一致):
下游开发者的模板都在 `ktr/template/` 文件夹里(App Router 式强约定:目录即路由,文件名固定),静态资源放 `ktr/public/`(dev server 当 publicDir 服务,构建时复制到产物 `assets/`;标记里和 CSS `url()` 里的 `/xxx` 引用恒等于 `<dir.assets>/xxx`,SSR 产出 HTML 时框架按 `html.assetsInlineLimit` 内联为 base64 或转为 `file://` 绝对路径,三态位置一致;CSS `url()` 走同一阈值,构建期 Vite 输出的 `/assets/*`(依赖包字体等)按产物目录解析):

- `ktr/template/<板块>/<模板>/index.tsx`:模板组件,默认导出 `defineTemplate(...)`;只有这个固定写法会注册为 `<板块>/<模板>`,裸写的 `.tsx` 不注册。
- `ktr/template/<板块>/<模板>/mock.ts`:类型安全的 TS mock(固定文件名),会被自动导出,面板中只读。
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,5 @@
"engines": {
"node": ">=18.0.0"
},
"time": "2026-08-29 02:03:30"
"time": "2026-09-16 12:19:48"
}
8 changes: 7 additions & 1 deletion packages/core/src/build/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 58 additions & 2 deletions packages/core/src/config/loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'

import { defu } from 'defu'
Expand Down Expand Up @@ -78,6 +79,61 @@ const loadUserConfig = async (cwd: string, configFile?: string): Promise<KtrConf

const resolvePath = (root: string, value: string): string => (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 项目根目录。
Expand All @@ -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')
Expand Down Expand Up @@ -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')

Expand Down
123 changes: 108 additions & 15 deletions packages/core/src/runtime/html-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ body {
/** 转义双引号,让值可以安全嵌入 HTML 属性。 */
const attr = (value: string): string => value.replace(/"/g, '&quot;')

/**
* 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 调用方显式提供的主题变量,可能为空或部分字段。
Expand Down Expand Up @@ -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 文本;文件缺失时返回空字符串。
*/
Expand All @@ -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]
}
}

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/runtime/template-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,12 @@ export interface HtmlWrapperOptions {
*/
assetsDir?: string
/**
* 标记资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:
* 资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:
* 不超过阈值的资源改写为 base64 data URI,超过的改写为 file:// 绝对路径;
* 函数形式按文件自行决定是否内联。
*
* 同时作用于标记资源(`<img src="/...">`)和 CSS 里 `url()` 引用的资源
* (含依赖包 CSS 带进来的字体),大字体因此不会 base64 进每一份 HTML。
* @default 4096
*/
assetsInlineLimit?: number | ((filePath: string) => boolean)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -304,8 +308,10 @@ export interface KtrConfig {
*/
standalone?: KtrStandaloneConfig
/**
* 额外注入 SSR HTML 的样式文件列表(相对项目根目录),
* 内容会被内联进渲染产物的 `<style>` 标签,CSS 里相对路径的 `url()` 资源会转成 data URI。
* 额外注入 SSR HTML 的样式文件列表(相对项目根目录,也可以直接写依赖包里的文件,
* 如 `'some-font-pkg/index.css'`——按 Node 解析,pnpm 的 `.pnpm` 真实路径同样命中)。
* 内容会被内联进渲染产物的 `<style>` 标签,CSS 里引用到的资源按 `html.assetsInlineLimit`
* 决定内联为 data URI 还是转成 file:// 绝对路径。
* @example extraStylePaths: ['ktr/template/print.css']
*/
extraStylePaths?: string[]
Expand Down Expand Up @@ -336,9 +342,10 @@ export interface KtrConfig {
*/
headExtra?: string
/**
* 标记资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:
* 模板里 `/` 开头的资源引用(对应 `<dir.assets>/` 下的文件)在 SSR 产出 HTML 时,
* 不超过阈值的内联为 base64,超过的转为 file:// 绝对路径;函数形式按文件决定。
* 资源内联阈值(字节),语义同 Vite 的 assetsInlineLimit:SSR 产出 HTML 时,
* 标记里 `/` 开头的资源引用(对应 `<dir.assets>/` 下的文件)和 CSS `url()` 引用的资源
* (含依赖包 CSS 带进来的字体)都按它决定形态——不超过阈值内联为 base64,
* 超过的转为 file:// 绝对路径;函数形式按文件决定。
* @default 4096
*/
assetsInlineLimit?: number | ((filePath: string) => boolean)
Expand Down
46 changes: 46 additions & 0 deletions packages/core/tests/build/build-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import fg from 'fast-glob'
import { describe, expect, it, vi } from 'vitest'

import { buildTemplates } from '../../src/build'
import { HtmlWrapper } from '../../src/runtime'

const testDir = path.dirname(fileURLToPath(import.meta.url))
const fixtureRoot = path.resolve(testDir, '../fixtures/mini-project')
Expand Down Expand Up @@ -69,6 +70,51 @@ describe('buildTemplates', () => {
expect(fs.existsSync(path.join(skipped, 'dist/template/assets'))).toBe(false)
})

it('依赖包里的 CSS 与字体:pnpm 的 .pnpm 布局能解析,大字体不进 HTML 的 base64', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ktr-dep-css-'))
fs.cpSync(fixtureRoot, root, { recursive: true })
fs.unlinkSync(path.join(root, 'templates/index.ts'))
fs.writeFileSync(
path.join(root, 'karin.template.ts'),
"export default { dir: { template: 'templates', cssEntry: 'templates/style.css' } }\n",
'utf-8'
)

// 伪造 pnpm 布局:根 node_modules 下只有软链,真实文件在 .pnpm/<包名>@<版本>/node_modules/<包名>。
const packageDir = path.join(root, 'node_modules/.pnpm/dep-font@1.0.0/node_modules/dep-font')
fs.mkdirSync(path.join(packageDir, 'fonts'), { recursive: true })
fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name: 'dep-font', version: '1.0.0' }), 'utf-8')
fs.writeFileSync(
path.join(packageDir, 'index.css'),
"@font-face{font-family:DepSmall;src:url('./fonts/small.woff2') format('woff2')}\n" +
"@font-face{font-family:DepBig;src:url('./fonts/big.woff2') format('woff2')}\n",
'utf-8'
)
fs.writeFileSync(path.join(packageDir, 'fonts/small.woff2'), 'tiny-font', 'utf-8')
fs.writeFileSync(path.join(packageDir, 'fonts/big.woff2'), 'x'.repeat(6000), 'utf-8')
fs.symlinkSync(packageDir, path.join(root, 'node_modules/dep-font'), 'junction')

fs.writeFileSync(
path.join(root, 'templates/style.css'),
"@import 'tailwindcss';\n@source './**/*.{ts,tsx}';\n@import 'dep-font/index.css';\n",
'utf-8'
)

const result = await buildTemplates({ root })
const css = fs.readFileSync(result.cssPath, 'utf-8')
// 依赖包 CSS 被内联进来:小字体由构建期内联,大字体落到产物 assets/ 并按 /assets/ 引用。
expect(css).toContain('@font-face')
expect(css).toContain('data:font/woff2;base64,')
expect(css).toContain('/assets/')
expect(await fg('assets/*.woff2', { cwd: path.join(root, 'dist/template') })).toHaveLength(1)

// 渲染期把 /assets/* 解析到产物里的真实文件:截图页是 file://,留着根路径就是静默回退到系统字体。
const html = new HtmlWrapper({ cssPath: result.cssPath }).wrapContent('<p>hi</p>', { scale: 1 })
expect(html).toContain('data:font/woff2;base64,')
expect(html).toMatch(/url\(["']?file:\/\/\/[^"']*big-[^"']*\.woff2["']?\)/)
expect(html).not.toContain('url("/assets/')
})

it('write: false 时不落盘、不打印日志,产物以内存形式返回', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ktr-build-memory-'))
fs.cpSync(fixtureRoot, root, { recursive: true })
Expand Down
Loading
Loading