diff --git a/packages/compiler/README.md b/packages/compiler/README.md
index 9733e240..1641ea2e 100644
--- a/packages/compiler/README.md
+++ b/packages/compiler/README.md
@@ -60,6 +60,24 @@ for (const file of files) fs.copyFileSync(file, path.join(publicDir, path.basena
每次浏览器构建都会拿 esbuild 的 metafile 对着这份清单自检:产物改了名、被挪进子目录、被拆出新 chunk、或某个资源开始 import 别的文件(`import` / `require` / 动态 import 都算,一带上就不再是自包含的单文件),以及清单里的文件名和 `package.json` 的 exports 对不上(改名只改了一边),构建当场失败,而不是几个月后在某个宿主那里 404。`toolchain.browser.js` 不在清单里——它由宿主用自己的打包器 import(`@dimina-kit/compiler/toolchain`),拷过去也没人 fetch。
+### 自定义文件类型(方言):声明一次,编译器和宿主用同一份
+
+`options.fileTypes` 让工程用自己的扩展名,比如千岛(qd)方言的 `.qdml`/`.qdss`/`.qds` 对应 `.wxml`/`.wxss`/`.wxs`。麻烦的是这份配置不止编译器要用:编辑器的语言映射、模板校验、预览时找页面模板,宿主自己也要按同一套规则判断文件角色。各处手写 `/\.(wxml|qdml)$/` 的结果是漏掉内置的 `.ddml`,而且同一个方言在几个仓库里各抄一份,谁改了另一边不会知道。
+
+`@dimina-kit/compiler/file-types` 就是那一份(同时发 ESM 和 CJS,无依赖):
+
+```js
+import { QD_FILE_TYPES, resolveFileTypes, hasExt } from '@dimina-kit/compiler/file-types'
+
+await pool.compile({ files, workPath, options: { fileTypes: QD_FILE_TYPES } })
+
+const { templateExts, styleExts, viewScriptExts, viewScriptTags } = resolveFileTypes(QD_FILE_TYPES)
+// templateExts: ['.wxml', '.ddml', '.qdml'] ← 内置在前,自定义在后,顺序即查找优先级
+hasExt('pages/index/index.QDML', templateExts) // true,大小写不敏感
+```
+
+`resolveFileTypes()` 算的是**编译器这次实际会认的**扩展名和内联标签:合并内置项、规范化(去空白、转小写、补一个前导点)、去重,并丢掉占用其他角色或 `.js`/`.ts`/`.json` 的项——`template: ['js']` 会把页面逻辑当模板解析,所以直接不接受。规则是照编译器 `env.js` 的 `normalizeFileTypes` 写的(直接 import 它会把 `node:fs` 和整个配置解析一起拉进来),`test:file-types` 读 env.js 源码比对内置列表和两条校验正则,上游一改这里就红。
+
## 架构
本包是**编译器与文件系统之间的一层适配**,再往上叠一层**编排**。真正的编译逻辑在 `dimina` 子模块的 `@dimina/compiler`,本包用一个**无后端的 fs 转发 shim** 把它每一次 `fs.xxx` 指向下游注入的 fs;`pool` 则在上面替下游管好 worker 池与并行——下游不再手写任何 worker/合并逻辑。
@@ -437,7 +455,7 @@ pnpm --filter @dimina-kit/compiler build:types # 仅 dist/types/*.d.ts
## 测试
-`pnpm --filter @dimina-kit/compiler test`(也就是 `turbo run test` 会跑到的那份)包含四份契约测试——静态资源清单(`test:browser-assets`)、错误码(`test:error-codes`)、二进制入参播种(`test:binary-seed`)和二进制产物保真(`test:binary-outputs`)。最后一份要拿 `dist` 里的真实 bundle 跑,但它自己不构建:turbo 里 `@dimina-kit/compiler#test` 依赖本包的 `build`,构建只发生一次,测试期间没有人再往 `dist` 写。脱离 turbo 单跑时用带构建的 `test:binary-outputs`(`test:binary-outputs:prebuilt` 是不构建的那个入口)。下面这些各自要先构建,按需单跑。
+`pnpm --filter @dimina-kit/compiler test`(也就是 `turbo run test` 会跑到的那份)包含六份契约测试——静态资源清单(`test:browser-assets`)、错误码(`test:error-codes`)、二进制入参播种(`test:binary-seed`)、二进制产物保真(`test:binary-outputs`)、方言扩展名(`test:file-types`)和方言穿过编译池(`test:pool-filetypes`)。后两类里要拿 `dist` 真实 bundle 跑的那几份自己不构建:turbo 里 `@dimina-kit/compiler#test` 依赖本包的 `build`,构建只发生一次,测试期间没有人再往 `dist` 写。脱离 turbo 单跑时用带构建的 `test:binary-outputs` / `test:pool-filetypes`(带 `:prebuilt` 后缀的是不构建的那个入口)。下面这些各自要先构建,按需单跑。
测试里用 memfs 扮演「下游 fs」:
@@ -455,6 +473,7 @@ pnpm --filter @dimina-kit/compiler test:stage-load-retry # stage 工
pnpm --filter @dimina-kit/compiler test:browser-assets # 静态资源清单:改名/新 chunk/出现静态 import 都会被构建期检查拦下
pnpm --filter @dimina-kit/compiler test:error-codes # 错误码:worker 自己判定的失败(工具链导入)带着码原样传到调用方,其余记为 compiler-stage-error
pnpm --filter @dimina-kit/compiler test:stage-toolchain # 真实 stage worker bundle:每个 stage 都加载工具链、按 URL 记忆、导入失败带错误码
+pnpm --filter @dimina-kit/compiler test:file-types # 方言扩展名:合并/规范化行为,以及内置列表与校验正则和编译器 env.js 逐字一致
pnpm --filter @dimina-kit/compiler test:binary-seed # 入参里的 Uint8Array 播种成文件(memfs 自己的 fromJSON 会把它变成目录)
pnpm --filter @dimina-kit/compiler test:binary-outputs # 页面引用的图片走完整编译后逐字节相同,文本产物仍是字符串
```
@@ -471,6 +490,7 @@ pnpm --filter @dimina-kit/compiler test:binary-outputs # 页面引
- `src/pool-node.js` — **Node 编排池** `createNodeCompilerPool` + dmcc drop-in 默认导出 `build()`:常驻 worker_threads、真实磁盘、全局 build 串行、死 worker 懒复活、idle 自动收缩(`idleShrinkMs`)。
- `src/stage-worker-node.js` — Node 常驻 stage worker:spawn 时按 workerData 里的 stage 身份预热本 stage 工具链,恢复 storeInfo → `runStage(stage, { sourcemap })` 写共享 staging 目录;应答 `{ type: 'introspect' }` 报告本 realm 已加载的重依赖。
- `src/toolchain.js` — 写 `toolchainSetupURL` 模块的可选助手(`installOxc` / `installEsbuildFromURL`,后者内置 esbuild-wasm 静态资源的 Blob-URL 兜底)。导出为 `@dimina-kit/compiler/toolchain`。
+- `src/file-types.js` — 自定义文件类型(方言)的权威声明:内置扩展名、合并规则 `resolveFileTypes`、qd 方言常量 `QD_FILE_TYPES`。导出为 `@dimina-kit/compiler/file-types`(ESM + CJS)。
- `src/browser-assets.js` — 浏览器静态资源清单与契约(`COMPILER_BROWSER_ASSETS` / `resolveBrowserAssets`,见上文),构建期检查也用它。导出为 `@dimina-kit/compiler/browser-assets`。
- `src/error-codes.js` — 两个 pool 共用的错误码表 `COMPILER_ERROR_CODES` 与判定 `isInfrastructureError`(见上文),经 `./pool` 与 `./pool-node` 再导出。
- `src/failure-hints.js` — Node 侧「一条原始报错文字该记哪个码」的判定(`errorCodeForMessage` / `tagFailure`),以及 oxc 绑定缺失、esbuild 二进制被封在 app.asar 这两种打包问题的中文提示(`oxcNativeBindingHint` / `esbuildAsarSpawnHint`,经 `./pool-node` 再导出)。单独成文件是为了让它不牵连 `worker_threads` 和编译器实体,`test:error-codes` 能直接驱动。
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index a2c29835..c0f4485a 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -47,6 +47,11 @@
"require": "./dist/browser-assets.cjs",
"default": "./dist/browser-assets.js"
},
+ "./file-types": {
+ "types": "./dist/types/file-types.d.ts",
+ "require": "./dist/file-types.cjs",
+ "default": "./dist/file-types.js"
+ },
"./package.json": "./package.json"
},
"files": [
@@ -69,7 +74,8 @@
"test:realm-reuse": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-realm-reuse.js",
"test:pool-node": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-node.js",
"test:pool-scopehash": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-scopehash.js",
- "test": "pnpm run test:browser-assets && pnpm run test:error-codes && pnpm run test:binary-seed && pnpm run test:binary-outputs:prebuilt",
+ "test": "pnpm run test:browser-assets && pnpm run test:error-codes && pnpm run test:binary-seed && pnpm run test:binary-outputs:prebuilt && pnpm run test:file-types && pnpm run test:pool-filetypes:prebuilt",
+ "test:file-types": "node scripts/test-file-types.js",
"test:binary-seed": "node scripts/test-binary-seed.js",
"test:binary-outputs": "node scripts/build-compiler.js node && node scripts/build-compiler.js browser && pnpm run test:binary-outputs:prebuilt",
"test:binary-outputs:prebuilt": "node --import ./scripts/register-kit.js scripts/test-binary-outputs.js",
@@ -88,7 +94,8 @@
"test:stage-toolchain": "node scripts/build-compiler.js browser && node scripts/test-stage-toolchain.js",
"test:stage-worker-message-order": "node scripts/build-compiler.js node && node scripts/test-stage-worker-message-order.js",
"test:stage-load-retry": "node scripts/build-compiler.js node && node scripts/test-stage-load-retry.js",
- "test:pool-filetypes": "node scripts/build-compiler.js node && node scripts/build-compiler.js browser && node scripts/test-pool-filetypes.js"
+ "test:pool-filetypes": "node scripts/build-compiler.js node && node scripts/build-compiler.js browser && pnpm run test:pool-filetypes:prebuilt",
+ "test:pool-filetypes:prebuilt": "node scripts/test-pool-filetypes.js"
},
"dependencies": {
"@babel/parser": "^7.29.7",
diff --git a/packages/compiler/scripts/build-compiler.js b/packages/compiler/scripts/build-compiler.js
index b25d894c..705ab119 100644
--- a/packages/compiler/scripts/build-compiler.js
+++ b/packages/compiler/scripts/build-compiler.js
@@ -246,17 +246,20 @@ for (const b of builds) {
// The static-asset manifest itself: dependency-free string code, emitted in both
// modes (either build alone leaves a usable dist) and in both formats, because the
// hosts that copy these files are as often CommonJS build scripts as ESM ones.
-for (const [format, outfile] of [['esm', 'browser-assets.js'], ['cjs', 'browser-assets.cjs']]) {
- await esbuild.build({
- entryPoints: [path.join(root, 'src/browser-assets.js')],
- outfile: path.join(root, 'dist', outfile),
- bundle: true,
- format,
- target: ['es2022'],
- logLevel: 'warning',
- })
+// src/file-types.js(方言扩展名)走同一条路:宿主的编辑器配置和文件分类也常常在 CJS 里。
+for (const name of ['browser-assets', 'file-types']) {
+ for (const [format, ext] of [['esm', 'js'], ['cjs', 'cjs']]) {
+ await esbuild.build({
+ entryPoints: [path.join(root, `src/${name}.js`)],
+ outfile: path.join(root, 'dist', `${name}.${ext}`),
+ bundle: true,
+ format,
+ target: ['es2022'],
+ logLevel: 'warning',
+ })
+ }
+ console.log(`✅ built dist/${name}.js + dist/${name}.cjs`)
}
-console.log('✅ built dist/browser-assets.js + dist/browser-assets.cjs')
// The browser bundles double as static files a host copies and serves. Their names
// and the "self-contained, imports nothing" rule are stated once in
diff --git a/packages/compiler/scripts/test-file-types.js b/packages/compiler/scripts/test-file-types.js
new file mode 100644
index 00000000..aa302c18
--- /dev/null
+++ b/packages/compiler/scripts/test-file-types.js
@@ -0,0 +1,93 @@
+// src/file-types.js 把编译器的合并规则抄了一份出来(env.js 直接 import 会拉进 node:fs 和
+// 整个配置解析,宿主只想知道扩展名)。抄来的东西会漂:所以这里直接读 env.js 源码,比对内置
+// 列表、保留扩展名和两条规范化正则——上游加一种内置方言或改一条正则,这个测试就红,而不是
+// 等某个宿主把文件分错类。后半段是合并行为本身。
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import {
+ BUILTIN_STYLE_EXTS,
+ BUILTIN_TEMPLATE_DIRECTIVE_PREFIXES,
+ BUILTIN_TEMPLATE_EXTS,
+ BUILTIN_VIEW_SCRIPT_EXTS,
+ BUILTIN_VIEW_SCRIPT_TAGS,
+ QD_FILE_TYPES,
+ RESERVED_EXTS,
+ hasExt,
+ resolveFileTypes,
+} from '../src/file-types.js'
+
+let failed = 0
+const chk = (cond, msg) => { if (cond) { console.log(`✅ ${msg}`) } else { console.log(`❌ ${msg}`); failed++ } }
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
+
+const ENV_PATH = fileURLToPath(new URL('../../../dimina/fe/packages/compiler/src/env.js', import.meta.url))
+const env = readFileSync(ENV_PATH, 'utf8')
+
+const listOf = (name) => {
+ const m = env.match(new RegExp(`const ${name} = \\[([^\\]]*)\\]`))
+ if (!m) throw new Error(`${name} 不在 ${ENV_PATH} 里了——编译器改了内置文件类型的写法,先看它现在怎么写`)
+ return [...m[1].matchAll(/'([^']*)'/g)].map((x) => x[1])
+}
+const regexOf = (fnName) => {
+ const start = env.indexOf(`function ${fnName}(`)
+ if (start < 0) throw new Error(`${fnName} 不在 ${ENV_PATH} 里了——先看编译器现在怎么校验扩展名`)
+ const m = env.slice(start).match(/!(\/\S+?\/)\.test\(v\)/)
+ if (!m) throw new Error(`${fnName} 里的规范化正则找不到了——先看编译器现在怎么校验扩展名`)
+ return m[1]
+}
+
+chk(same([...BUILTIN_TEMPLATE_EXTS], listOf('DEFAULT_TEMPLATE_EXTS')),
+ `内置模板扩展名与编译器一致(${BUILTIN_TEMPLATE_EXTS.join(' ')})`)
+chk(same([...BUILTIN_TEMPLATE_DIRECTIVE_PREFIXES], listOf('DEFAULT_TEMPLATE_DIRECTIVE_PREFIXES')),
+ `内置模板指令前缀与编译器一致(${BUILTIN_TEMPLATE_DIRECTIVE_PREFIXES.join(' ')})`)
+chk(same([...BUILTIN_STYLE_EXTS], listOf('DEFAULT_STYLE_EXTS')),
+ `内置样式扩展名与编译器一致(${BUILTIN_STYLE_EXTS.join(' ')})`)
+chk(same([...BUILTIN_VIEW_SCRIPT_EXTS], listOf('DEFAULT_VIEW_SCRIPT_EXTS')),
+ `内置视图脚本扩展名与编译器一致(${BUILTIN_VIEW_SCRIPT_EXTS.join(' ')})`)
+chk(same([...BUILTIN_VIEW_SCRIPT_TAGS], listOf('DEFAULT_VIEW_SCRIPT_TAGS')),
+ `内置视图脚本标签与编译器一致(${BUILTIN_VIEW_SCRIPT_TAGS.join(' ')})`)
+
+{
+ const block = env.slice(env.indexOf('const RESERVED_EXTS = new Set(['))
+ const extra = [...block.slice(0, block.indexOf('])')).matchAll(/'([^']*)'/g)].map((x) => x[1])
+ chk(same(RESERVED_EXTS.filter((e) => !BUILTIN_TEMPLATE_EXTS.includes(e) && !BUILTIN_STYLE_EXTS.includes(e) && !BUILTIN_VIEW_SCRIPT_EXTS.includes(e)), extra),
+ `保留扩展名里非内置的那几个与编译器一致(${extra.join(' ')})`)
+}
+
+chk(regexOf('normalizeExt') === '/^[a-z0-9_-]+$/', `扩展名校验正则与编译器一致(${regexOf('normalizeExt')})`)
+chk(regexOf('normalizeTag') === '/^[a-z][a-z0-9_-]*$/', `标签名校验正则与编译器一致(${regexOf('normalizeTag')})`)
+
+// 合并行为
+{
+ const r = resolveFileTypes(QD_FILE_TYPES)
+ chk(same(r.templateExts, ['.wxml', '.ddml', '.qdml']), `qd 方言的模板扩展名(${r.templateExts.join(' ')})`)
+ chk(same(r.styleExts, ['.wxss', '.ddss', '.less', '.scss', '.sass', '.qdss']), `qd 方言的样式扩展名(${r.styleExts.join(' ')})`)
+ chk(same(r.viewScriptExts, ['.wxs', '.qds']), `qd 方言的视图脚本扩展名(${r.viewScriptExts.join(' ')})`)
+ chk(same(r.viewScriptTags, ['wxs', 'dds', 'qds']), `视图脚本扩展名同时派生内联标签(${r.viewScriptTags.join(' ')})`)
+ chk(r.templateDirectivePrefixes.includes('qd'), `自定义模板扩展名派生出指令前缀(${r.templateDirectivePrefixes.join(' ')})`)
+}
+
+{
+ const r = resolveFileTypes()
+ chk(same(r.templateExts, [...BUILTIN_TEMPLATE_EXTS]), '不传 fileTypes 就只有内置项')
+ r.templateExts.push('.mine')
+ chk(!BUILTIN_TEMPLATE_EXTS.includes('.mine'), '返回的是副本,改它不会污染内置列表')
+}
+
+chk(same(resolveFileTypes({ template: ['js', 'ts', 'json', 'wxss'] }).templateExts, ['.wxml', '.ddml']),
+ '占用逻辑/配置/其他角色扩展名的自定义项被丢弃')
+chk(same(resolveFileTypes({ template: ['.QDML', 'qdml', ' qdml '] }).templateExts, ['.wxml', '.ddml', '.qdml']),
+ '带点、大写、带空白的写法规范化成同一项,且只留一份')
+chk(same(resolveFileTypes({ template: ['a/b', 'q*d', '', ' '] }).templateExts, ['.wxml', '.ddml']),
+ '带路径分隔符或元字符的项被丢弃')
+chk(same(resolveFileTypes({ viewScript: ['9qd'] }).viewScriptTags, ['wxs', 'dds']),
+ '数字开头不能当标签名(扩展名可以,标签名不行)')
+chk(same(resolveFileTypes({ viewScript: ['9qd'] }).viewScriptExts, ['.wxs', '.9qd']),
+ '同一项作为扩展名仍然有效')
+
+chk(hasExt('pages/index/index.QDML', resolveFileTypes(QD_FILE_TYPES).templateExts), 'hasExt 大小写不敏感')
+chk(!hasExt('pages/index/index.json', resolveFileTypes(QD_FILE_TYPES).templateExts), 'hasExt 不误判 .json')
+chk(!hasExt(undefined, BUILTIN_TEMPLATE_EXTS), 'hasExt 对非字符串返回 false')
+
+console.log(failed ? `\n❌ ${failed} 条文件类型断言失败。` : '\n✅ 方言常量与编译器的合并规则一致。')
+process.exit(failed ? 1 : 0)
diff --git a/packages/compiler/scripts/test-pool-filetypes.js b/packages/compiler/scripts/test-pool-filetypes.js
index 949a8162..c784583f 100644
--- a/packages/compiler/scripts/test-pool-filetypes.js
+++ b/packages/compiler/scripts/test-pool-filetypes.js
@@ -11,8 +11,7 @@
// options-threading fix and passes (green) with it — no reimplementation of the
// protocol handlers under test.
//
-// Fixture mirrors qdmp's e2e "qd-app" (see
-// ~/code/qdmp/main/packages/qdmp-devtools/e2e/qd-app): a page using .qdml/.qdss/.qds
+// Fixture is a minimal qd-dialect app: a page using .qdml/.qdss/.qds
// instead of .wxml/.wxss/.wxs, with a view
// script and a `{{ m.shout(title) }}` mustache expression — so a correct compile
// must recognize the custom template AND the custom view-script extension.
@@ -26,6 +25,7 @@
// must run first.
import { transform } from 'esbuild'
import 'oxc-parser'
+import { QD_FILE_TYPES } from '../src/file-types.js'
await transform('const __warm = 1', {})
@@ -53,7 +53,9 @@ const FIXTURE_FILES = {
'pages/index/index.json': JSON.stringify({ navigationBarTitleText: 'QD Ext Index' }),
'pages/index/index.qds': "function shout(text) {\n return text + '!'\n}\n\nmodule.exports = {\n shout: shout,\n}\n",
}
-const FILE_TYPES_OPTIONS = { fileTypes: { template: ['qdml'], style: ['qdss'], viewScript: ['qds'] } }
+// 用导出的常量本身,而不是再抄一份字面量:这样这个测试同时证明 QD_FILE_TYPES 就是能让
+// .qdml/.qdss/.qds 工程编出来的那份配置。
+const FILE_TYPES_OPTIONS = { fileTypes: QD_FILE_TYPES }
let failed = false
const chk = (cond, msg) => { if (!cond) { failed = true; console.error(`❌ ${msg}`) } else console.log(`✅ ${msg}`) }
diff --git a/packages/compiler/src/compile-core.js b/packages/compiler/src/compile-core.js
index 3d8eaaf7..54688a89 100644
--- a/packages/compiler/src/compile-core.js
+++ b/packages/compiler/src/compile-core.js
@@ -516,7 +516,7 @@ let compileChain = Promise.resolve()
* Compile a mini-program against a caller-injected fs. Calls are serialized per
* realm (see the singleton note above). Convenience wrapper that runs
* `setupCompile` + all stages + `collectOutputs` in one realm.
- * @param {{ fs: object, workPath?: string, options?: { fileTypes?: { template?: string[], style?: string[], viewScript?: string[] } } }} opts
+ * @param {{ fs: object, workPath?: string, options?: { fileTypes?: { template?: readonly string[], style?: readonly string[], viewScript?: readonly string[] } } }} opts
* fs: a node:fs replacement (sync subset: existsSync/readFileSync/
* readdirSync{withFileTypes}/statSync/writeFileSync/mkdirSync{recursive}/
* copyFileSync/rmSync), already seeded with the project source under
diff --git a/packages/compiler/src/file-types.js b/packages/compiler/src/file-types.js
new file mode 100644
index 00000000..96f54edb
--- /dev/null
+++ b/packages/compiler/src/file-types.js
@@ -0,0 +1,158 @@
+/**
+ * 自定义文件类型(方言)的权威声明,供宿主与编译器共用。
+ *
+ * 编译器认哪些扩展名,是 `options.fileTypes` 决定的(见 README「自定义文件类型」)。
+ * 但宿主除了把这份配置传给编译器,自己也要按同一套规则判断文件角色——编辑器语言映射、
+ * 模板校验、预览时找页面模板。它们各自手写 `/\.(wxml|qdml)$/` 这类正则时,漏掉内置的
+ * `.ddml` 只是不显眼,真正的问题是同一个方言在几个仓库里各抄一份,谁改了另一边不会知道。
+ *
+ * 所以这里把两件事放在一处:内置扩展名与合并规则(`resolveFileTypes`),以及千岛(qd)
+ * 方言这份具体配置(`QD_FILE_TYPES`)。宿主 import 它,而不是再抄一遍。
+ *
+ * 合并规则是照着编译器 `env.js` 的 `normalizeFileTypes` 写的(本包不能直接 import 它:
+ * env.js 会拉进 node:fs 和整个配置解析)。`test:file-types` 直接读 env.js 源码比对内置
+ * 列表和两条规范化正则,上游一改这里就红。
+ *
+ * 纯字符串处理、无依赖,任何运行时都能加载;同时发 ESM(dist/file-types.js)和
+ * CJS(dist/file-types.cjs),因为 Electron 主进程那侧常常是 CommonJS。
+ */
+
+/** 内置模板扩展名。顺序即同名文件的查找优先级。 */
+export const BUILTIN_TEMPLATE_EXTS = Object.freeze(['.wxml', '.ddml'])
+/** 内置模板指令前缀(`wx:if` 的 `wx`)。自定义模板扩展名会再派生一个。 */
+export const BUILTIN_TEMPLATE_DIRECTIVE_PREFIXES = Object.freeze(['wx', 'dd', 'a'])
+/** 内置样式扩展名,含预处理器。 */
+export const BUILTIN_STYLE_EXTS = Object.freeze(['.wxss', '.ddss', '.less', '.scss', '.sass'])
+/** 内置视图脚本扩展名。 */
+export const BUILTIN_VIEW_SCRIPT_EXTS = Object.freeze(['.wxs'])
+/** 内置视图脚本内联标签(``)。 */
+export const BUILTIN_VIEW_SCRIPT_TAGS = Object.freeze(['wxs', 'dds'])
+
+/**
+ * 自定义项不得占用的扩展名:所有内置角色 + 逻辑(.js/.ts)+ 配置(.json)。
+ * 占用会导致跨角色串编——`template: ['js']` 会把页面逻辑当模板解析。
+ */
+export const RESERVED_EXTS = Object.freeze([
+ ...BUILTIN_TEMPLATE_EXTS,
+ ...BUILTIN_STYLE_EXTS,
+ ...BUILTIN_VIEW_SCRIPT_EXTS,
+ '.js',
+ '.ts',
+ '.json',
+])
+
+/**
+ * 千岛(qd)方言:`.qdml`/`.qdss`/`.qds` 分别对应 `.wxml`/`.wxss`/`.wxs`。
+ * 编译器、编辑器语言映射和宿主自己的文件分类都以这一份为准。
+ * @type {Readonly}
+ */
+export const QD_FILE_TYPES = Object.freeze({
+ template: Object.freeze(['qdml']),
+ style: Object.freeze(['qdss']),
+ viewScript: Object.freeze(['qds']),
+})
+
+/**
+ * 列表声明成只读:本包发出去的 `QD_FILE_TYPES` 是冻结的,写成可变数组的话,下游
+ * 「再 push 一个扩展名」能通过类型检查,运行时才抛 TypeError。传入方向不受影响,
+ * 普通数组照样能喂给 `resolveFileTypes`。
+ *
+ * @typedef {object} FileTypes
+ * @property {readonly string[]} [template] 追加的模板扩展名,如 ['qdml'](点可带可不带)
+ * @property {readonly string[]} [style] 追加的样式扩展名
+ * @property {readonly string[]} [viewScript] 追加的视图脚本扩展名,同时派生同名内联标签
+ */
+
+/**
+ * @typedef {object} ResolvedFileTypes
+ * @property {string[]} templateExts 内置在前、自定义在后
+ * @property {string[]} templateDirectivePrefixes 模板指令前缀
+ * @property {string[]} styleExts
+ * @property {string[]} viewScriptExts
+ * @property {string[]} viewScriptTags 内联标签名(不带点)
+ */
+
+/**
+ * 规范化成扩展名:去空白、转小写、补一个前导点。只接受字母、数字、连字符和下划线;
+ * 空串、路径分隔符和其他元字符返回 null 由调用方丢弃——扩展名会用来拼尾部匹配正则。
+ * @param {unknown} raw
+ * @returns {string | null}
+ */
+function normalizeExt(raw) {
+ if (typeof raw !== 'string') return null
+ const v = raw.trim().toLowerCase().replace(/^\.+/, '')
+ if (!/^[a-z0-9_-]+$/.test(v)) return null
+ return `.${v}`
+}
+
+/**
+ * 规范化成内联标签名:同上但必须以字母开头且不带点——标签名会拼进选择器,
+ * 放行元字符会让 `'qds,view'` 误选到 。
+ * @param {unknown} raw
+ * @returns {string | null}
+ */
+function normalizeTag(raw) {
+ if (typeof raw !== 'string') return null
+ const v = raw.trim().toLowerCase().replace(/^\.+/, '')
+ if (!/^[a-z][a-z0-9_-]*$/.test(v)) return null
+ return v
+}
+
+/**
+ * @param {readonly string[]} builtins
+ * @param {unknown} custom
+ * @param {(raw: unknown) => string | null} normalizer
+ * @param {Set} [reserved]
+ * @returns {string[]}
+ */
+function mergeUnique(builtins, custom, normalizer, reserved) {
+ const out = [...builtins]
+ const seen = new Set(out)
+ if (Array.isArray(custom)) {
+ for (const raw of custom) {
+ const n = normalizer(raw)
+ if (n && !seen.has(n) && !reserved?.has(n)) {
+ seen.add(n)
+ out.push(n)
+ }
+ }
+ }
+ return out
+}
+
+/**
+ * 算出一次编译里编译器实际认的扩展名与标签——内置的加上这份 `fileTypes` 追加的。
+ * 宿主用它做文件分类,就不会和编译器给出不同答案。
+ * @param {FileTypes} [fileTypes]
+ * @returns {ResolvedFileTypes}
+ */
+export function resolveFileTypes(fileTypes = {}) {
+ const ft = fileTypes || {}
+ const reserved = new Set(RESERVED_EXTS)
+ const templateExts = mergeUnique(BUILTIN_TEMPLATE_EXTS, ft.template, normalizeExt, reserved)
+ return {
+ templateExts,
+ templateDirectivePrefixes: [...new Set([
+ ...BUILTIN_TEMPLATE_DIRECTIVE_PREFIXES,
+ ...templateExts.map((extension) => {
+ const name = extension.slice(1)
+ return name.endsWith('ml') ? name.slice(0, -2) : name
+ }).filter(Boolean),
+ ])],
+ styleExts: mergeUnique(BUILTIN_STYLE_EXTS, ft.style, normalizeExt, reserved),
+ viewScriptExts: mergeUnique(BUILTIN_VIEW_SCRIPT_EXTS, ft.viewScript, normalizeExt, reserved),
+ viewScriptTags: mergeUnique(BUILTIN_VIEW_SCRIPT_TAGS, ft.viewScript, normalizeTag),
+ }
+}
+
+/**
+ * 路径是不是这组扩展名之一。大小写不敏感,`resolveFileTypes` 的任一列表都能直接喂进来。
+ * @param {string} filePath
+ * @param {readonly string[]} exts
+ * @returns {boolean}
+ */
+export function hasExt(filePath, exts) {
+ if (typeof filePath !== 'string') return false
+ const lower = filePath.toLowerCase()
+ return exts.some((ext) => lower.endsWith(ext))
+}
diff --git a/packages/compiler/src/pool-node.js b/packages/compiler/src/pool-node.js
index 75b205fe..cc9f63e3 100644
--- a/packages/compiler/src/pool-node.js
+++ b/packages/compiler/src/pool-node.js
@@ -65,7 +65,7 @@ const DEFAULT_IDLE_SHRINK_MS = 300000
let chain = Promise.resolve()
/**
- * @typedef {{ template?: string[], style?: string[], viewScript?: string[] }} FileTypes
+ * @typedef {{ template?: readonly string[], style?: readonly string[], viewScript?: readonly string[] }} FileTypes
* @typedef {{ sourcemap?: boolean, fileTypes?: FileTypes }} BuildOptions
* @typedef {{ appId: string, name: string, path: string }} BuildResult
*/
diff --git a/packages/compiler/src/pool.js b/packages/compiler/src/pool.js
index 0d429894..e6668402 100644
--- a/packages/compiler/src/pool.js
+++ b/packages/compiler/src/pool.js
@@ -212,7 +212,7 @@ export function createCompilerPool(options) {
* @param {{
* files: Record,
* workPath?: string,
- * options?: { fileTypes?: { template?: string[], style?: string[], viewScript?: string[] } },
+ * options?: { fileTypes?: { template?: readonly string[], style?: readonly string[], viewScript?: readonly string[] } },
* } | Record} input
* Source files are text or raw bytes: an image belongs in the map as a Uint8Array,
* not as a decoded string (postMessage carries it as bytes, and the stage worker
diff --git a/packages/compiler/tsconfig.types.json b/packages/compiler/tsconfig.types.json
index 196790b2..d5238bb6 100644
--- a/packages/compiler/tsconfig.types.json
+++ b/packages/compiler/tsconfig.types.json
@@ -23,6 +23,7 @@
"src/pool-node.js",
"src/stage-worker.js",
"src/toolchain.js",
- "src/browser-assets.js"
+ "src/browser-assets.js",
+ "src/file-types.js"
]
}
diff --git a/packages/compiler/types-fixture/consumer.ts b/packages/compiler/types-fixture/consumer.ts
index 2b533c26..52d622ae 100644
--- a/packages/compiler/types-fixture/consumer.ts
+++ b/packages/compiler/types-fixture/consumer.ts
@@ -4,9 +4,10 @@
// degrades to `any`), the deliberately wrong call stops erroring and tsc fails
// this file with "unused '@ts-expect-error' directive".
-import { collectOutputs, STAGE_NAMES } from '@dimina-kit/compiler'
+import { collectOutputs, compileMiniApp, STAGE_NAMES } from '@dimina-kit/compiler'
import { COMPILER_BROWSER_ASSETS, resolveBrowserAssets } from '@dimina-kit/compiler/browser-assets'
import { initToolchain } from '@dimina-kit/compiler/browser'
+import { QD_FILE_TYPES, hasExt, resolveFileTypes } from '@dimina-kit/compiler/file-types'
import { createCompilerPool } from '@dimina-kit/compiler/pool'
import { createNodeCompilerPool } from '@dimina-kit/compiler/pool-node'
import '@dimina-kit/compiler/stage-worker'
@@ -23,6 +24,16 @@ void assetDir
resolveBrowserAssets(COMPILER_BROWSER_ASSETS)
const outputs: Record = collectOutputs({ fs: {}, targetPath: '/dist' })
+
+const { templateExts } = resolveFileTypes(QD_FILE_TYPES)
+const isTemplate: boolean = hasExt('pages/index/index.qdml', templateExts)
+void isTemplate
+// The published dialect is frozen at runtime, so the declarations have to say so —
+// otherwise "just add one more extension" type-checks and throws in the browser.
+// @ts-expect-error QD_FILE_TYPES' lists are readonly
+QD_FILE_TYPES.template?.push('qdx')
+// @ts-expect-error hasExt takes the extension list, not a single extension
+hasExt('pages/index/index.qdml', '.qdml')
void outputs
// @ts-expect-error products are text OR bytes; a downstream must narrow before treating one as a string
const textOnly: Record = collectOutputs({ fs: {}, targetPath: '/dist' })
@@ -70,5 +81,13 @@ export async function compileOnce(): Promise {
const nodePool = createNodeCompilerPool({ stages: ['logic'] })
void nodePool
+
+// The published dialect must go into the compile entries exactly as the README shows
+// it. Its lists are frozen, so an entry still asking for mutable `string[]` rejects
+// it with TS2322 — a downstream would have to copy the arrays to get past its own
+// type-check, which is how each host ends up with its own drifting copy again.
+void pool.compile({ files: { 'app.json': '{}' }, workPath: '/project', options: { fileTypes: QD_FILE_TYPES } })
+void compileMiniApp({ fs: {}, workPath: '/project', options: { fileTypes: QD_FILE_TYPES } })
+void nodePool.build('/out', '/project', true, { fileTypes: QD_FILE_TYPES })
// @ts-expect-error stages is a list of stage names
createNodeCompilerPool({ stages: 'logic' })