diff --git a/package-lock.json b/package-lock.json index 75da27a9..bf927a7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,9 @@ "@eslint/js": "^10.0.1", "@types/fs-extra": "11.0.4", "@types/glob": "^7.2.0", + "@types/is-relative": "^1.0.2", "@types/node": "^22.19.0", + "@types/normalize-path": "^3.0.2", "@types/socket.io-client": "^3.0.0", "@types/ws": "^8.18.2", "@typescript-eslint/eslint-plugin": "^8.71.0", @@ -53,10 +55,12 @@ "glob": "^7.2.0", "globals": "^17.13.0", "husky": "^7.0.0", + "is-relative": "^1.0.0", "jiti": "^2.7.0", "mini-types": "^0.1.7", "miniprogram-api-typings": "^5.2.3", "ng-packagr": "22.1.1", + "normalize-path": "^3.0.0", "prettier": "3.2.5", "pretty-quick": "4.0.0", "rimraf": "5.0.5", @@ -1410,7 +1414,6 @@ "version": "22.1.7", "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz", "integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1423,7 +1426,6 @@ "version": "22.1.7", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz", "integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/core": "8.0.1", @@ -1456,7 +1458,6 @@ "version": "22.1.7", "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz", "integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -8880,6 +8881,13 @@ "@types/node": "*" } }, + "node_modules/@types/is-relative": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/is-relative/-/is-relative-1.0.2.tgz", + "integrity": "sha512-U20pV/DYvGi5olAC+Z6Z6tnq6abgHVQdyPtmDkOVASXlYcENL87Rp2WK4EyjstF4tGyRtjhE1AL04GeSgAagNg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsesc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", @@ -8955,6 +8963,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/normalize-path": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/normalize-path/-/normalize-path-3.0.2.tgz", + "integrity": "sha512-DO++toKYPaFn0Z8hQ7Tx+3iT9t77IJo/nDiqTXilgEP+kPNIYdpS9kh3fXuc53ugqwp9pxC1PVjCpV1tQDyqMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -11232,7 +11247,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -11757,7 +11771,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -16363,6 +16376,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", @@ -16469,6 +16495,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -20638,7 +20677,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -20666,7 +20704,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { @@ -21155,7 +21192,6 @@ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -21352,7 +21388,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -23251,7 +23286,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -23325,6 +23360,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 291c7f0d..814777a8 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,9 @@ "@eslint/js": "^10.0.1", "@types/fs-extra": "11.0.4", "@types/glob": "^7.2.0", + "@types/is-relative": "^1.0.2", "@types/node": "^22.19.0", + "@types/normalize-path": "^3.0.2", "@types/socket.io-client": "^3.0.0", "@types/ws": "^8.18.2", "@typescript-eslint/eslint-plugin": "^8.71.0", @@ -72,10 +74,12 @@ "glob": "^7.2.0", "globals": "^17.13.0", "husky": "^7.0.0", + "is-relative": "^1.0.0", "jiti": "^2.7.0", "mini-types": "^0.1.7", "miniprogram-api-typings": "^5.2.3", "ng-packagr": "22.1.1", + "normalize-path": "^3.0.0", "prettier": "3.2.5", "pretty-quick": "4.0.0", "rimraf": "5.0.5", diff --git a/script/gen-builder-schema.ts b/script/gen-builder-schema.ts index 468b7acf..a47f051c 100644 --- a/script/gen-builder-schema.ts +++ b/script/gen-builder-schema.ts @@ -1,53 +1,47 @@ +import { toJsonSchema } from '@valibot/to-json-schema'; +import type { ConversionConfig } from '@valibot/to-json-schema'; import * as fs from 'fs'; import * as path from 'path'; -import { PlatformType } from '../src/builder/platform/platform'; +import { libraryOptionsSchema } from '../src/builder/library/options-schema'; +import { + applicationOptionsSchema, + applicationSchemaDefinitions, + assetPatternSchema, +} from '../src/builder/vite/options-schema'; +import { vitestOptionsSchema } from '../src/builder/vitest/vite/options-schema'; const nodeRequire = require as unknown as NodeRequire; /** - * 生成 `src/builder/vite/schema.json`(application builder 的选项 schema)。 + * 生成三个 builder 的选项 schema:`src/builder/vite/schema.json`(application)、 + * `src/builder/library/schema.json`、`src/builder/vitest/vite/schema.json`。 * - * ## 为什么是生成物 + * ## 形状只写一份 * - * 本构建器是 `@angular/build:application` 的小程序对应物,选项名和语义都应当 - * 跟它对齐。以前 schema 是手抄的(而且抄的是 webpack 时代 `build-angular:browser` - * 那一版),结果两头都漂:上游删掉的化石字段留在这儿变成了「配了没反应」, - * 上游新增的字段我们又一个没接。 + * 选项形状写在 valibot 里(`src/builder/vite/options-schema.ts`、 + * `src/builder/library/options-schema.ts`、`src/builder/vitest/vite/options-schema.ts`), + * 这里只负责转换:`@valibot/to-json-schema` 转成 JSON Schema,再补 title / + * description / 来源版本。同一份形状还负责 builder 入口的选项解析(默认值在那儿补) + * 和选项的 TS 类型,三者不可能各说各话。两条链路同名同义的字段走 + * `sharedMpOptionFields`,不在两个文件里各写一遍。 * - * 现在改成**白名单生成**:基底直接读 devDependencies 里 - * `@angular/build/src/builders/application/schema.json`(版本由 package.json - * 钉住,升版本即同步),只挑 `INHERIT` / `OVERRIDE` 里点过名的字段, - * 再拼上本包独有的 `LOCAL`。 + * ## 和上游的关系 * - * 上游新增字段不会自动进来 —— 想支持就得往表里加一行,加的时候顺手把语义 - * 落到 `createMiniProgramViteConfig` 里。这样 schema 里出现的每一个字段, - * 都必然在 builder 里有读取点。 + * application 构建器是 `@angular/build:application` 的小程序对应物,选项名和语义都跟它 + * 对齐;library 对应 `@angular/build:ng-packagr`。上游在这里不再是形状来源,而是 + * **对账对象**(版本由 package.json 钉住): * - * ## 丢弃清单(配了会直接校验失败,而不是静默无效) + * - `UPSTREAM_FIELDS` 点过名的字段上游删了 → 当场报错,提醒同步; + * - 上游新增字段既没进 `UPSTREAM_FIELDS` 也没进 `DISCARDED` → 报出来,逼一次表态; + * - `DISCARDED` 是丢弃清单:这些字段本包不接,配了会直接校验失败,而不是静默无效。 * - * 小程序没有 HTML / HTTP / SW / SSR / Web Worker 这些概念,下列上游字段整体不要: - * - * - 入口与 HTML:`browser`(本包叫 `main`)、`index`、`appShell`、`baseHref` - * - 服务端渲染:`server`、`ssr`、`prerender`、`outputMode` - * - HTTP 部署:`deployUrl`、`security`、`crossOrigin`、`subresourceIntegrity` - * - Service Worker:`serviceWorker` - * - Web Worker:`webWorkerTsConfig` - * - 与打包器绑定、Vite 侧无对应物:`loader`、`extractLicenses`、`clearScreen` - * - webpack 化石:`vendorChunk`、`commonChunk`、`buildOptimizer`、`extractCss`、 - * `showCircularDependencies`、`namedChunks`(rollup 的 chunk 本来就带名字)、 - * `resourcesOutputPath`、`poll`(本包 watch 走 fs.watch,无轮询) - * - 未实现:`scripts`(全局脚本入口)、`allowedCommonJsDependencies` - * (Vite 没有对应的告警可关)、`verbose`(logLevel 固定 info,见 index.ts 注释) - * - i18n 构建期内联:`localize`、`i18nMissingTranslation`、`i18nDuplicateTranslation` - * (本包走 `polyfills` 里声明 `@angular/localize` 那条运行时路径) - * - `aot`:恒 AOT,没有 jit 路径 + * 上游新增字段不会自动进来 —— 想支持就往 options-schema 加字段并登记进 + * `UPSTREAM_FIELDS`,加的时候顺手把语义落到 `createMiniProgramViteConfig` 里。 + * 这样 schema 里出现的每一个字段,都必然在 builder 里有读取点。 + * `vitest` builder 是本包独有的,没有上游可对账,生成物也不带来源版本戳。 * * 用法:`npm run gen:schema` 重新生成,`npm run check:schema` 只校验不写盘 * (CI 用,防止有人手改 schema.json 或升了 @angular/build 却没同步)。 - * - * `library` builder 走同一套:基底是 `@angular/build` 的 ng-packagr schema - * (project / tsConfig / watch / poll 四个,本包无独有项),产物是 - * `src/builder/library/schema.json`。 */ const UPSTREAM_PACKAGE = '@angular/build'; @@ -61,17 +55,13 @@ const LIBRARY_OUTPUT = path.resolve( __dirname, '../src/builder/library/schema.json', ); - -/** - * `platform` 的合法值直接取自平台注册表,不在 schema 里手抄一遍(手拄了就会漏)。 - * `library` 是构建器内部给 library 产物用的伪平台,不是用户能配的目标。 - */ -const PLATFORM_ENUM = Object.values(PlatformType).filter( - (value) => value !== PlatformType.library, +const VITEST_OUTPUT = path.resolve( + __dirname, + '../src/builder/vitest/vite/schema.json', ); -/** 上游定义原样继承的字段(描述、类型、默认值全部跟上游) */ -const INHERIT = [ +/** 名字与语义取自上游的字段。上游删掉任何一个都会在这里报错,而不是静默少字段。 */ +const APPLICATION_UPSTREAM_FIELDS = [ 'assets', 'styles', 'stylePreprocessorOptions', @@ -87,228 +77,76 @@ const INHERIT = [ 'externalDependencies', 'budgets', 'statsJson', + 'tsConfig', + 'outputPath', + 'polyfills', + 'optimization', ] as const; -/** 继承上游、但本包改了定义或默认值的字段。reason 说明为什么要改。 */ -const OVERRIDE: Record = { - tsConfig: { - def: { - type: 'string', - description: - 'The full path for the TypeScript configuration file, relative to the current workspace.', - }, - reason: '本包 tsconfig 必填(入口范围靠它的 fileNames 判定),上游是可选。', - }, - outputPath: { - def: { - type: 'string', - description: - 'The full path for the new output directory, relative to the current workspace.', - }, - reason: - '上游的 {base,browser,server,media} 对象形态是给 web 产物分目录用的,' + - '小程序产物目录由平台固定,只收字符串。', - }, - polyfills: { - def: { - description: 'Polyfills to be included in the build.', - oneOf: [ - { - type: 'array', - description: 'Polyfills to be included in the build.', - items: { type: 'string' }, - }, - { - type: 'string', - description: 'The polyfills to be included in the build.', - }, - ], - }, - reason: - '比上游放宽(上游只收数组):串 / 数组两种写法照旧都收,减少心智。' + - '上游自己也在 options.ts 里把串归一成数组,所以放宽不会和上游分叉。' + - 'localize 的判定见 vite/index.ts resolveLocalizeInit。', - }, - optimization: { - def: { - description: - 'Enables optimization of the build output: minification of scripts and ' + - 'styles, tree-shaking and dead-code elimination. `styles.inlineCritical` ' + - 'and `fonts` are web-only and have no effect here.', - default: false, - oneOf: [ - { - type: 'object', - properties: { - scripts: { - type: 'boolean', - description: 'Enables optimization of the scripts output.', - default: true, - }, - styles: { - description: 'Enables optimization of the styles output.', - default: true, - oneOf: [ - { - type: 'object', - properties: { - minify: { - type: 'boolean', - description: - 'Minify CSS definitions by removing extraneous whitespace and comments.', - default: true, - }, - }, - additionalProperties: false, - }, - { type: 'boolean' }, - ], - }, - }, - additionalProperties: false, - }, - { type: 'boolean' }, - ], - }, - reason: - '上游默认 true(web 产物默认压缩)。本包默认 false:小程序的 dev 流程是' + - '微信开发者工具盯着产物目录,默认出未压缩代码才可读;需要压缩请显式打开' + - '或走 production configuration。同时只保留 scripts / styles.minify ' + - '两个真有对应物的子项。', - }, -}; +const LIBRARY_UPSTREAM_FIELDS = [ + 'project', + 'tsConfig', + 'watch', + 'poll', +] as const; -/** 本包独有字段(上游没有)。 */ -const LOCAL: Record = { - platform: { - type: 'string', - // 枚举值直接取自 PlatformType,不在这里手抄一遗 - enum: PLATFORM_ENUM, - description: '小程序平台', - default: 'wx', - }, - pages: { - type: 'array', - description: '页面配置', - default: [], - items: { $ref: '#/definitions/assetPattern' }, - }, - subpackages: { - type: 'array', - description: - '分包入口:写法与 pages 一样,output 就是分包 root(约定 root 同时是源码目录' + - '与产物目录)。配了就不用在 app 配置里写 subpackages:root 取 output,' + - '分包页由扫出来的入口算;自己写了同一个 root 就以自己那份为准', - items: { - type: 'object', - properties: { - glob: { type: 'string', description: '匹配入口文件的 glob' }, - input: { type: 'string', description: '源目录' }, - output: { - type: 'string', - description: '分包 root(同时是产物目录)', - }, - ignore: { - description: 'An array of globs to ignore.', - type: 'array', - items: { type: 'string' }, - }, - independent: { - type: 'boolean', - description: '独立分包:不依赖主包即可运行', - }, - }, - required: ['glob', 'input', 'output'], - additionalProperties: false, - }, - }, - customTabbar: { - type: 'array', - description: - '自定义 tabBar 入口的源文件位置。产物目录由平台决定(微信系 custom-tab-bar,' + - '支付宝 customize-tab-bar),output 字段会被覆盖;' + - '不配则默认取 /custom-tab-bar 下的 *.entry.ts', - items: { $ref: '#/definitions/assetPattern' }, - }, - main: { - type: 'string', - description: - 'The full path for the main entry point to the app, relative to the current workspace.', - }, - appJson: { - type: 'string', - description: - '结构化 app 配置源文件(相对 workspaceRoot)。与 assets 里的静态 app.json ' + - '不是二选一:静态那份是底稿,本文件只写要补的字段,已写过的 key 不动,' + - 'pages 追加。环境不同就换这个文件,平台不同用文件里的 _platform 段。', - }, - projectConfig: { - type: 'string', - description: - '结构化 project 配置源文件(相对 workspaceRoot),只影响 project 配置文件。' + - '与 appJson 各管一个输出文件,字段不互通;没写的字段由内置默认值打底。', - }, - appJsonValidate: { - type: 'string', - enum: ['error', 'warn', 'off'], - default: 'error', - description: - 'app 配置校验严格度。只作用于 appJson 通道;只有静态 app.json 的工程固定 warn' + - '(那是从别的项目搬过来的,合规与否不由我们负责)。off 用于先绕过校验把工程跑起来。', - }, - deriveCondition: { - type: 'boolean', - default: false, - description: - '自动生成 project 配置的调试启动项(condition),开发者工具的「编译模式」' + - '会列出全部页面。只是方便一下,需要精确控制启动参数仍在 projectConfig 里写。', - }, - nativeComponentsDir: { - type: 'string', - description: - '原生小程序自定义组件目录(相对 workspaceRoot,如 wxcomponents)。' + - '配置后整个目录拷进产物,模板命中原生标签自动注入 usingComponents。', - }, - dedupe: { - type: 'array', - items: { type: 'string' }, - default: [], - description: - 'Forced single-instance packages, passed straight to Vite `resolve.dedupe`. ' + - 'Empty by default: nothing is injected into your module resolution. Only ' + - 'needed when the library is consumed via `file:` / `npm link`, where the ' + - 'linked copy own node_modules holds a second @angular/core.', - }, - format: { - type: 'string', - enum: ['cjs', 'es'], - default: 'cjs', - description: - '产物模块格式。小程序 JS 运行时是 CommonJS,默认 cjs;' + - '只有在宿主侧确认支持 ESM 时才改成 es。', - }, - viteConfig: { - type: 'string', - description: - '自定义 vite 配置的钩子文件(相对 workspaceRoot)。文件默认导出一个 ' + - '(config, ctx) => config 的函数:config 是构建器组装完的最终 vite 配置,' + - '随便改,返回新对象或就地改都行;构建器不校验钩子的改动。' + - '.ts / .mts / .cts 由 jiti 加载,.js / .mjs / .cjs 走原生 import。', - }, - tagNameClass: { - type: 'string', - enum: ['mapped', 'all', 'off'], - default: 'mapped', - description: - '给元素补 `tag-name-<原标签>` 标记的策略。模板写 `div` 而 wxml 里已经是 ' + - '`view`,`div` 选择器落空,这个 class 就是补回来的把手。' + - 'mapped(默认)只在映射改写了标签时输出;all 每个元素都输出;' + - 'off 一律不输出(标记会进 class,也就等于每个元素多一个 token)。', - }, +/** + * 上游有、本包不要的字段 → 为什么不要。空话别写:这一栏是给「升上游版本时纠结要不要接」的人看的。 + */ +const APPLICATION_DISCARDED: Record = { + // 入口与 HTML:小程序没有 HTML 入口 + browser: '入口在本包叫 main', + index: '没有 HTML 入口', + appShell: '没有 HTML', + baseHref: '没有 HTML', + // 服务端渲染 + server: '没有服务端产物', + ssr: '没有服务端渲染', + prerender: '没有预渲染', + outputMode: '产物目录由平台固定,没有 browser/server 之分', + // HTTP 部署 + deployUrl: '没有 HTTP 部署路径', + security: '没有 HTTP 响应头', + crossOrigin: '没有 HTTP', + subresourceIntegrity: '没有 HTTP', + // Service Worker + serviceWorker: '没有 Service Worker', + ngswConfigPath: '没有 Service Worker', + // Web Worker + webWorkerTsConfig: '没有 Web Worker', + // 与打包器绑定,Vite / rollup 侧无对应物 + loader: 'esbuild 的 loader 由 tsconfig 决定,不接受手配', + extractLicenses: '没有单独的许可证抽取', + clearScreen: '日志由 builderContext 出,不清屏', + // webpack 化石 + vendorChunk: 'rollup 没有 vendor chunk 这个概念', + commonChunk: 'rollup 自动做公共 chunk', + buildOptimizer: '没有对应的优化阶段', + extractCss: '样式本来就不在 JS 里', + showCircularDependencies: 'rollup 自己会报循环依赖', + namedChunks: 'rollup 的 chunk 本来就带名字', + resourcesOutputPath: '媒体产物路径由平台固定', + poll: '本包 watch 走 fs.watch,无轮询', + progress: '没有进度条可关', + // 未实现 + scripts: '全局脚本入口未实现', + allowedCommonJsDependencies: 'Vite 没有对应的告警可关', + verbose: 'logLevel 固定 info,见 vite/index.ts', + // i18n 构建期内联:本包走 polyfills 里声明 @angular/localize 那条运行时路径 + localize: 'i18n 走运行时 @angular/localize', + i18nFile: '同上', + i18nFormat: '同上', + i18nLocale: '同上', + i18nMissingTranslation: '同上', + i18nDuplicateTranslation: '同上', + // 恒 AOT,没有 jit 路径 + aot: '恒 AOT', }; +const LIBRARY_DISCARDED: Record = {}; + function readUpstream(subpath: string): { - schema: Record; + properties: Record; version: string; } { // 只能先解析 package.json 再手工拼:@angular/build 的 exports 只开了 @@ -326,151 +164,208 @@ function readUpstream(subpath: string): { const version = JSON.parse( fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'), ).version; - return { - schema: JSON.parse(fs.readFileSync(path.join(pkgDir, subpath), 'utf-8')), - version, - }; + const schema = JSON.parse( + fs.readFileSync(path.join(pkgDir, subpath), 'utf-8'), + ); + return { properties: schema.properties ?? {}, version }; } /** - * 上游字段 → 生成物字段:没被点名的一个不进,删掉的 / 未继承的都算丢弃。 - * 两个 builder 共用这套检查,上游删字段时当场报错而不是静默少字段。 + * 与上游对账:删了的字段必须同步,新增的字段必须表态(接进 UPSTREAM_FIELDS + * 或写进 DISCARDED),两边都不做就报出来。 */ -function pickUpstream( +function assertUpstream( + label: string, upstreamProperties: Record, version: string, - inherit: readonly string[], - override: Record, - local: Record = {}, -): Record { - const missing = [...inherit, ...Object.keys(override)].filter( - (key) => !(key in upstreamProperties), - ); + fields: readonly string[], + discarded: Record, +): void { + const missing = fields.filter((key) => !(key in upstreamProperties)); if (missing.length) { throw new Error( `${UPSTREAM_PACKAGE}@${version} 里找不到这些字段:${missing.join(', ')}。` + - `上游删字段了,请同步本脚本的 INHERIT / OVERRIDE 表。`, + `上游删字段了,请同步 options-schema 与 UPSTREAM_FIELDS。`, ); } - const overlap = Object.keys(override).filter((key) => inherit.includes(key)); - if (overlap.length) { - throw new Error( - `字段同时出现在 INHERIT 和 OVERRIDE:${overlap.join(', ')}`, + const undeclared = Object.keys(upstreamProperties).filter( + (key) => !fields.includes(key) && !(key in discarded), + ); + if (undeclared.length) { + console.warn( + `[${label}] ${UPSTREAM_PACKAGE}@${version} 有 ${undeclared.length} 个字段` + + `本包既没接也没登记:${undeclared.join(', ')}。` + + `接就加进 UPSTREAM_FIELDS,不接就写进 DISCARDED。`, ); } +} - const properties: Record = {}; - for (const key of inherit) { - const { 'x-user-analytics': _analytics, ...rest } = upstreamProperties[key]; - properties[key] = rest; +/** + * 转换器出的是 draft-2020 风格的 `$defs`,本包的 schema 一直是 draft-07 的 + * `definitions`(`build-cli-schema.ts` 内联进 workspace schema 时按这个前缀改写 $ref)。 + * 顺手去掉空 `required: []`,那是 valibot 对全可选项对象的写法,JSON Schema 里是噪音。 + */ +function toDraft7(node: unknown): unknown { + if (Array.isArray(node)) { + return node.map(toDraft7); } - for (const [key, { def }] of Object.entries(override)) { - properties[key] = def; + if (!node || typeof node !== 'object') { + return node; + } + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key === '$defs') { + result['definitions'] = toDraft7(value); + continue; + } + if (key === 'required' && Array.isArray(value) && !value.length) { + continue; + } + if (key === '$ref' && typeof value === 'string') { + result[key] = value.replace('#/$defs/', '#/definitions/'); + continue; + } + result[key] = toDraft7(value); } - return Object.assign(properties, local); + return result; } -/** `library` builder 直接全量继承 ng-packagr 的四个字段,本包无独有项。 */ -const LIBRARY_INHERIT = ['project', 'tsConfig', 'watch', 'poll'] as const; +function convert( + schema: Parameters[0], + definitions?: ConversionConfig['definitions'], +): Record { + return toDraft7( + toJsonSchema(schema, { + target: 'draft-07', + errorMode: 'throw', + definitions, + }), + ) as Record; +} -interface Generated { - schema: Record; - version: string; +/** metadata 里手写的 required 必须真有其字段,否则 CLI 侧会要求一个不存在的选项。 */ +function assertRequiredResolvable(schema: Record): void { + const properties = (schema.properties ?? {}) as Record; + const required = (schema.required ?? []) as string[]; + const missing = required.filter((key) => !(key in properties)); + if (missing.length) { + throw new Error(`required 里有未声明的字段:${missing.join(', ')}`); + } } -function buildApplicationSchema(): Generated { - const { schema: upstream, version } = readUpstream( - APPLICATION_SCHEMA_SUBPATH, - ); - return { - version, - schema: { - $schema: 'http://json-schema.org/draft-07/schema', - title: 'Mini-program Vite build schema', - description: - '小程序构建 builder(Vite / esbuild 版,替代 webpack 链路)。' + - `本文件由 script/gen-builder-schema.ts 从 ${UPSTREAM_PACKAGE}@${version} 生成,勿手改。`, - type: 'object', - properties: pickUpstream( - upstream.properties ?? {}, - version, - INHERIT, - OVERRIDE, - LOCAL, - ), - additionalProperties: false, - required: ['outputPath', 'main', 'tsConfig'], - definitions: upstream.definitions, - }, +interface Target { + /** builders.json 里的名字,只用于日志与报错 */ + name: string; + output: string; + title: string; + /** 生成物 description 的第一句 */ + summary: string; + schema: Parameters[0]; + definitions?: ConversionConfig['definitions']; + /** 上游对账;本包独有的 builder(vitest)没有上游 */ + upstream?: { + subpath: string; + fields: readonly string[]; + discarded: Record; }; } -function buildLibrarySchema(): Generated { - const { schema: upstream, version } = readUpstream(LIBRARY_SCHEMA_SUBPATH); - return { - version, - schema: { - $schema: 'http://json-schema.org/draft-07/schema', - title: 'Mini-program library (ng-packagr) schema', - description: - '小程序 library builder(ng-packagr 链路)。' + - `本文件由 script/gen-builder-schema.ts 从 ${UPSTREAM_PACKAGE}@${version} 生成,勿手改。`, - type: 'object', - properties: pickUpstream( - upstream.properties ?? {}, - version, - LIBRARY_INHERIT, - {}, - ), - additionalProperties: false, - required: ['project'], +const TARGETS: Target[] = [ + { + name: 'application', + output: APPLICATION_OUTPUT, + title: 'Mini-program Vite build schema', + summary: '小程序构建 builder(Vite / esbuild 版,替代 webpack 链路)。', + schema: applicationOptionsSchema, + definitions: applicationSchemaDefinitions, + upstream: { + subpath: APPLICATION_SCHEMA_SUBPATH, + fields: APPLICATION_UPSTREAM_FIELDS, + discarded: APPLICATION_DISCARDED, }, - }; -} + }, + { + name: 'library', + output: LIBRARY_OUTPUT, + title: 'Mini-program library (ng-packagr) schema', + summary: '小程序 library builder(ng-packagr 链路)。', + schema: libraryOptionsSchema, + upstream: { + subpath: LIBRARY_SCHEMA_SUBPATH, + fields: LIBRARY_UPSTREAM_FIELDS, + discarded: LIBRARY_DISCARDED, + }, + }, + { + name: 'vitest', + output: VITEST_OUTPUT, + title: 'MiniProgram Vitest Target', + summary: '把 spec 编进小程序产物,由 vitest 通过 WebSocket 驱动执行。', + schema: vitestOptionsSchema, + // 只登记真被引用到的子形状,否则 $defs 里会多出没人引用的定义 + definitions: { assetPattern: assetPatternSchema }, + }, +]; -/** 生成物里带上来源版本,排查「schema 和依赖版本对不上」时不用猜。 */ -function withSourceVersion( - schema: Record, - version: string, -): Record { - return { ...schema, 'x-generated-from': `${UPSTREAM_PACKAGE}@${version}` }; +function render(target: Target): { + text: string; + source: string; + fields: number; +} { + const upstream = target.upstream + ? readUpstream(target.upstream.subpath) + : undefined; + if (target.upstream && upstream) { + assertUpstream( + target.name, + upstream.properties, + upstream.version, + target.upstream.fields, + target.upstream.discarded, + ); + } + const source = upstream + ? `${UPSTREAM_PACKAGE}@${upstream.version}` + : 'src/builder/vitest/vite/options-schema.ts'; + const schema: Record = { + $schema: 'http://json-schema.org/draft-07/schema', + title: target.title, + description: `${target.summary}本文件由 script/gen-builder-schema.ts 从 ${source} 生成,勿手改。`, + ...convert(target.schema, target.definitions), + ...(upstream ? { 'x-generated-from': source } : {}), + }; + assertRequiredResolvable(schema); + return { + text: `${JSON.stringify(schema, null, 2)}\n`, + source, + fields: Object.keys((schema.properties ?? {}) as object).length, + }; } -const TARGETS: { output: string; build: () => Generated }[] = [ - { output: APPLICATION_OUTPUT, build: buildApplicationSchema }, - { output: LIBRARY_OUTPUT, build: buildLibrarySchema }, -]; - function main(): void { const check = process.argv.includes('--check'); for (const target of TARGETS) { - const { schema, version } = target.build(); - const output = `${JSON.stringify(withSourceVersion(schema, version), null, 2)}\n`; + const { text, source, fields } = render(target); const name = path.relative(path.resolve(__dirname, '..'), target.output); if (check) { const current = fs.existsSync(target.output) ? fs.readFileSync(target.output, 'utf-8') : ''; - if (current !== output) { + if (current !== text) { console.error( - `${name} 与 ${UPSTREAM_PACKAGE}@${version} 不同步,` + + `${name} 与 ${source} 不同步,` + `请跑 npm run gen:schema 并把结果一起提交`, ); process.exit(1); } - console.log(`${name} 与 ${UPSTREAM_PACKAGE}@${version} 一致`); + console.log(`${name} 与 ${source} 一致`); continue; } - fs.writeFileSync(target.output, output); - console.log( - `已生成 ${name}(基底 ${UPSTREAM_PACKAGE}@${version},` + - `${Object.keys(schema.properties as object).length} 个字段)`, - ); + fs.writeFileSync(target.output, text); + console.log(`已生成 ${name}(对账 ${source},${fields} 个字段)`); } } if (require.main === module) { main(); } - -export { buildApplicationSchema as buildSchema, buildLibrarySchema }; diff --git a/src/builder/builder.prod.spec.ts b/src/builder/builder.prod.spec.ts index 837d09a9..717ebdf9 100644 --- a/src/builder/builder.prod.spec.ts +++ b/src/builder/builder.prod.spec.ts @@ -14,13 +14,12 @@ import { ALL_COMPONENT_NAME_LIST, ALL_PAGE_NAME_LIST, } from '../../test/util/file'; -// 主测试链路已切到 Vite builder(webpack 链路待删除) import { PlatformType } from './platform/platform'; import { runViteBuilder as runBuilder } from './vite'; const angularConfig = { ...DEFAULT_ANGULAR_CONFIG, - platform: PlatformType.wx, + platform: PlatformType.wx as const, buildOptimizer: true, optimization: true, extractLicenses: true, @@ -52,9 +51,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { const result = await harness.executeOnce(); expect(result).toBeTruthy(); expect(result.error).toBeFalsy(); - // 不能写 logs[0].level !== 'error': - // 1. Vite 链路可能一条日志都不产生,logs[0] 直接 undefined - // 2. 就算有,只看第一条也漏掉了后面的 error + // 只看 logs[0] 会漏:Vite 链路可能一条日志都不产生,也可能 error 不在第一条。 // 本意是「构建过程没有报错」,那就该查全部。 const errorLogs = result.logs.filter((l) => l.level === 'error'); expect({ errorLogs: errorLogs.map((l) => l.value) }).toEqual({ @@ -68,20 +65,13 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { /** * 生产构建下的 wxs 剥离回归。 * - * ## 复现的坑 + * wxs 的绑定应该被分析层从 Angular 模板里剥掉,只留在 wxml:逻辑层 js 里只应该看到枝叶数组属性, + * 不该出现 `fmt.money(...)` 这种逻辑层去调 wxs 的写法(运行时根本没有 `fmt`)。 * - * wxs(渲染层脚本)的绑定应该被分析层从 Angular 模板里剥掉,只留在 wxml: - * 逻辑层 js 里只应该看到「枝叶数组」属性(`[__wxXXXXXX]="[price()]"`), - * **不该**出现 `fmt.money(...)` 这种逻辑层去调 wxs 的鬼东西(运行时根本没有 `fmt`)。 - * - * 但 `fileReplacements` 非空时(生产配置里几乎必然带 environment 替换), - * analog 才会注册它的 `rollup-plugin-replace-files`(也是 `enforce: 'pre'`)。 - * 它和 `mini-program:wxs-strip` 的 resolveId 会互相把对方顶空:两个都返回 null, - * vite 退回默认解析 = 原组件文件,于是 Angular 那份**没剥离**的模板被编进 js - * (wxml 却还是对的,所以只表现为「js 里混进了 wxs」)。 - * dev 没有 environment 替换 ⇒ 插件根本没注册 ⇒ 全绿,只有 `build:prod` 会碎。 - * - * 所以这个用例的关键不是「跑个 prod」,而是 **prod + fileReplacements 同时在场**。 + * `fileReplacements` 非空时 analog 才会注册它的 `rollup-plugin-replace-files`(也是 `enforce: 'pre'`)。 + * 它和 `mini-program:wxs-strip` 的 resolveId 会互相把对方顶空,vite 退回默认解析 = 原组件文件, + * 于是 Angular 那份没剥离的模板被编进 js。dev 没有 environment 替换,插件根本没注册,只有 prod 会碎。 + * 所以这个用例的关键是 prod + fileReplacements 同时在场。 */ const WXS_SCRIPT = `var CURRENCY = '¥'; @@ -186,9 +176,8 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { // 1. 逻辑层 js 不该有任何 `fmt.xxx(` 调用(模板没被剥离的直接症状) expect(entryJs).not.toMatch(/\bfmt\.\w+\s*\(/); - // 2. 这份 js 必须是 AOT 编过的。退回原文件时 analog 不产出模板, - // vite 只把裸 TS 转译一遍,`templateUrl` 装饰器会原样留在产物里 - // (小程序里没有运行时编译器,也没有 .html,必碎)。 + // 2. 这份 js 必须是 AOT 编过的。退回原文件时 analog 不产出模板,vite 只把裸 TS 转译一遍, + // `templateUrl` 装饰器会原样留在产物里 expect(entryJs).not.toContain('templateUrl'); // 3. 剥离后的正确形态:枝叶数组属性 `__wxXXXXXX` expect(entryJs).toMatch(/__wx[0-9a-z]{4,}/); diff --git a/src/builder/builder.spec.ts b/src/builder/builder.spec.ts index 61c27f57..32b9d65a 100644 --- a/src/builder/builder.spec.ts +++ b/src/builder/builder.spec.ts @@ -28,20 +28,12 @@ const angularConfig = { /** * 全量构建的冒烟用例:跑通「页面 + 组件 + 库」整条链路,并核对产物扩展名。 * - * ## 为什么只跑 wx 和 zfb - * - * 十家平台以前各构建一次,一次 2s,光这个文件就 21s。但每条用例真正断言的 - * 只有两件事:构建没报错、产物扩展名对得上(`app.wxss` / `self.wxml` / - * 库组件的 `.qml` `.axml` …)。而「每家平台的 globalObject / 指令前缀 / - * 四种产物扩展名」已经被 `platform/platform-registry.spec.ts` 逐字钉死在 - * 一张表里(十家 1ms 跑完),构建侧的平台差异(define、`.wx.ts` 变体、 - * 死分支 DCE)另有 `vite/platform-flags.build.spec.ts` 覆盖。 - * - * 所以这里留两个代表:wx 是基准,zfb 是差异最大的一家 - * (全局对象 `my`、前缀 `a`、`.axml/.acss/.sjs`)。 - * 新增平台若带来了这两张表没覆盖的行为,再往数组里加。 + * 每条用例真正断言的只有两件事:构建没报错、产物扩展名对得上。而「每家平台的 globalObject / + * 指令前缀 / 四种产物扩展名」已被 `platform/platform-registry.spec.ts` 逐字钉死在一张表里, + * 构建侧的平台差异另有 `vite/platform-flags.build.spec.ts` 覆盖。所以这里留两个代表: + * wx 是基准,zfb 是差异最大的一家(全局对象 `my`、前缀 `a`、`.axml/.acss/.sjs`)。 */ -const SMOKE_PLATFORMS = [PlatformType.wx, PlatformType.zfb]; +const SMOKE_PLATFORMS = [PlatformType.wx, PlatformType.zfb] as const; describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { describe('builder-dev', () => { @@ -72,8 +64,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { }); expect(result).toBeTruthy(); expect(result.error).toBeFalsy(); - // Vite 链路可能不产生日志,logs[0] 会是 undefined; - // 本意是「构建没报错」,那就查全部而不是只看第一条。 + // Vite 链路可能不产生日志,logs[0] 会是 undefined;本意是「构建没报错」,那就查全部 expect( result.logs.filter((l) => l.level === 'error').map((l) => l.value), ).toEqual([]); diff --git a/src/builder/class-style-wxml.spec.ts b/src/builder/class-style-wxml.spec.ts index f47e6c34..bc3ee7c4 100644 --- a/src/builder/class-style-wxml.spec.ts +++ b/src/builder/class-style-wxml.spec.ts @@ -26,15 +26,9 @@ async function compile( } /** - * class / style 两条通道的按需输出。 - * - * 以前每个元素都无条件带 `class="{{nodeList[i].class}}"` 和 - * `style="{{nodeList[i].style}}"`,模板里用没用到都一样,实测占掉 wxml - * 近三成体积,数据侧还跟着发一份空串。现在编译期静态分析出「这个元素到底 - * 有没有 class / style 来源」,没有就整条属性都不输出,数据侧也不发。 - * - * 判据漏一条就是「运行时改了 class 而 wxml 不读」的静默丢样式,所以下面 - * 把每种写法逐个钉住,而不是只测一个正例。 + * class / style 两条通道的按需输出。编译期静态分析出「这个元素到底有没有 class / style 来源」, + * 没有就整条属性都不输出,数据侧也不发。 + * 判据漏一条就是「运行时改了 class 而 wxml 不读」的静默丢样式,所以下面把每种写法逐个钉住。 */ describe('class / style 按需输出', () => { it('两个通道都没用到的元素一个属性也不带', async () => { @@ -109,13 +103,9 @@ describe('class / style 按需输出', () => { }); /** - * `tag-name-<原标签>` 标记。 - * - * 用途是「模板写 div、wxml 里已经是 view」时补一个选中把手。映射前后一样 - * 的标签本来就能直接选中,带着它只是每个元素多一个 class token。 - * - * 标记由编译期烘成字面量(运行时不知道映射表),所以没有别的 class 来源的 - * 元素连数据通道都不用留 —— 一个 `class="tag-name-div"` 就完了。 + * `tag-name-<原标签>` 标记。用途是「模板写 div、wxml 里已经是 view」时补一个选中把手。 + * 映射前后一样的标签本来就能直接选中,带着它只是每个元素多一个 class token。 + * 标记由编译期烘成字面量(运行时不知道映射表),所以没有别的 class 来源的元素连数据通道都不用留。 */ describe('tag-name 标记', () => { it('mapped(默认):只给被改写的标签', async () => { diff --git a/src/builder/cli-schema.spec.ts b/src/builder/cli-schema.spec.ts index 61f3c6d9..3204e5f4 100644 --- a/src/builder/cli-schema.spec.ts +++ b/src/builder/cli-schema.spec.ts @@ -21,10 +21,9 @@ function definitionName(builder: string) { } /** - * 发布包里的 `lib/config/schema.json` 由 script/build-cli-schema.ts 生成: - * 基底直读 `@angular/cli/lib/config/schema.json`,再挂上本包三个 builder。 - * 这里钉死「结构没被改坏」——尤其是 target.oneOf 与兜底分支的互斥关系, - * 一旦漏改 not.enum,oneOf 会同时命中两条导致 angular.json 校验失败。 + * 发布包里的 `lib/config/schema.json` 由 script/build-cli-schema.ts 生成:基底直读 + * `@angular/cli/lib/config/schema.json`,再挂上本包三个 builder。这里钉死「结构没被改坏」—— + * 尤其是 target.oneOf 与兜底分支的互斥关系,一旦漏改 not.enum,oneOf 会同时命中两条导致校验失败。 */ describe('cli workspace schema:本包 builder 注入', () => { const schema = buildSchema(); @@ -59,8 +58,7 @@ describe('cli workspace schema:本包 builder 注入', () => { expect(inlined.$schema).toBeUndefined(); expect(inlined.required).toBeUndefined(); const json = JSON.stringify(inlined); - // 内联后所有内部 $ref 必须命名空间化到自身 definitions 下, - // 否则多个 builder 的同名 definition(assetPattern 等)会串在一起 + // 内联后所有内部 $ref 必须命名空间化到自身 definitions 下,否则多个 builder 的同名 definition 会串在一起 expect(json).not.toMatch(/"#\/definitions\/(?!AngularMiniprogram)/); for (const local of Object.keys(inlined.definitions ?? {})) { expect(json).toContain( diff --git a/src/builder/component-template-inject/change-component.build.spec.ts b/src/builder/component-template-inject/change-component.build.spec.ts index f53c657e..435ae284 100644 --- a/src/builder/component-template-inject/change-component.build.spec.ts +++ b/src/builder/component-template-inject/change-component.build.spec.ts @@ -16,19 +16,16 @@ import { PlatformType } from '../platform/platform'; import { runViteBuilder as runBuilder } from '../vite'; /** - * 端到端校验 changeComponent 在真实构建产物里对组件 template 函数的改写。 - * - * 全部走 AST(见 test/util/template-inject-ast.ts)。不用字符串/正则匹配: - * webpack 在不同 chunk 里对同一份注入代码排版不同(entry 是 `}if(rf & 2){`, - * vendor 会排成 `} if (rf & 2) {`,`;;` 会变成 `; ;`),字面量匹配会漏, - * 放宽空白又容易误判。 + * 端到端校验 changeComponent 在真实构建产物里对组件 template 函数的改写。全部走 AST + * (见 test/util/template-inject-ast.ts)。不用字符串/正则匹配:bundler 在不同 chunk 里对同一份 + * 注入代码排版不同,字面量匹配会漏,放宽空白又容易误判。 */ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { describe('component-template-inject: 构建产物 AST 校验', () => { it('每个有 rf & 1 的组件模板都被注入且只注入一次', async () => { const angularConfig = { ...DEFAULT_ANGULAR_CONFIG, - platform: PlatformType.wx, + platform: PlatformType.wx as const, sourceMap: false, }; const root = harness.host.root(); @@ -106,9 +103,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { } } - // 核心不变式:凡是有 rf & 1 的组件模板,都必须被注入。 - // 之前 OutsideTemplateComponent 就是因为选择器把 template 解析成了 - // StringLiteral 而被静默跳过,这条断言就是用来兜住这类漏注入的。 + // 核心不变式:凡是有 rf & 1 的组件模板,都必须被注入。这条断言用来兜住静默漏注入这类问题。 expect({ missed, malformed }).toEqual({ missed: [], malformed: [], diff --git a/src/builder/component-template-inject/change-component.spec.ts b/src/builder/component-template-inject/change-component.spec.ts index 5f2be00d..6fc1821d 100644 --- a/src/builder/component-template-inject/change-component.spec.ts +++ b/src/builder/component-template-inject/change-component.spec.ts @@ -91,8 +91,7 @@ describe('component-template-inject: changeComponent', () => { describe('if (rf & 2) 是空块', () => { it('不能崩,退化成补一个完整更新块', () => { // 空块时 statements 为 [],取 statements[length - 1] 得到 undefined, - // 老逻辑会在 insertNode 里读 getStart 直接抛 - // "Cannot read properties of undefined (reading 'getStart')" + // 老逻辑会在 insertNode 里读 getStart 直接抛。 const r = changeComponent(EMPTY_UPDATE_BLOCK); expect(r).toBeTruthy(); const body = templateBody(r!.content); @@ -102,14 +101,9 @@ describe('component-template-inject: changeComponent', () => { describe('回归:多组件文件 + 类上有 template 字段', () => { /** - * OutsideTemplateComponent 就是这个形状:类自己有个 `template` 字段, - * 同时 ɵɵdefineComponent 里也有 `template: function`。 - * - * 旧实现用 createCssSelectorForTs 的相邻兄弟组合符定位元数据对象, - * 同文件多组件时兄弟关系错位,`PropertyAssignment[name=template]::initializer` - * 会解析到一个 StringLiteral,initIfNode 拿不到,整个组件被 `continue` - * 静默跳过——函数照常返回结果,但一条 propertyChange 都没插进去。 - * 线上表现是该组件的属性变更不会同步到小程序侧。 + * OutsideTemplateComponent 就是这个形状:类自己有个 `template` 字段,同时 ɵɵdefineComponent + * 里也有 `template: function`。用相邻兄弟组合符定位元数据对象时,同文件多组件的兄弟关系会错位, + * `PropertyAssignment[name=template]::initializer` 会解析到一个 StringLiteral,整个组件被静默跳过。 */ it('两个组件都必须被注入', () => { const r = changeComponent(MULTI_WITH_TEMPLATE_FIELD); @@ -153,8 +147,7 @@ describe('component-template-inject: changeComponent', () => { }); it('空模板(无 rf & 1)不注入,但仍按契约返回结果', () => { - // 不能返回 undefined:SetupComponentDataService 靠返回值决定组件是否产出, - // 空模板组件也得正常产出,只是没有注入点而已 + // 不能返回 undefined:调用方靠返回值决定组件是否产出,空模板组件也得正常产出,只是没有注入点 const r = changeComponent(EMPTY_TEMPLATE); expect(r).toBeTruthy(); expect(r!.componentName).toBe('EmptyTpl'); @@ -167,8 +160,7 @@ describe('component-template-inject: changeComponent', () => { const r = changeComponent(TWO_COMPONENTS); expect(r!.componentName).toBe('Comp1'); const body = templateBody(r!.content); - // Comp1 走「有更新块」分支,Comp2 走「无更新块」分支, - // 两个组件的 template 里都应出现 propertyChange + // Comp1 走「有更新块」分支,Comp2 走「无更新块」分支,两个组件的 template 里都应出现 propertyChange const occurrences = (r!.content.match(/amp\.propertyChange/g) || []).length; // import 行本身不含 propertyChange,所以 2 次 = 两个组件各被插了一次 expect(occurrences).toBe(2); diff --git a/src/builder/component-template-inject/change-component.ts b/src/builder/component-template-inject/change-component.ts index 41affbf1..182ba2fc 100644 --- a/src/builder/component-template-inject/change-component.ts +++ b/src/builder/component-template-inject/change-component.ts @@ -6,15 +6,9 @@ const DEFINE_COMPONENT = 'ɵɵdefineComponent'; /** * 找到所有 `ɵɵdefineComponent({...})` 的元数据对象字面量。 - * - * 不用 createCssSelectorForTs 的 - * `PropertyAccessExpression[name=ɵɵdefineComponent]~SyntaxList ObjectLiteralExpression`: - * 那个相邻兄弟(`~`)组合符在同文件多组件时兄弟关系会错位,导致后面按名字取 - * template 时拿到完全不相干的节点(实测会拿到一个 StringLiteral), - * 于是整个组件被静默跳过、不注入。 - * - * webpack 还可能把调用包成 `(0, mod["ɵɵdefineComponent"])(...)`,所以按 callee - * 文本包含来判断。 + * 不用 createCssSelectorForTs 的相邻兄弟(`~`)组合符:它在同文件多组件时会错位, + * 导致后面按名字取 template 时拿到完全不相干的节点,整个组件被静默跳过。 + * webpack 还可能把调用包成 `(0, mod["ɵɵdefineComponent"])(...)`,所以按 callee 文本包含来判断。 */ function findDefineComponentMetaList( sf: ts.SourceFile, @@ -23,8 +17,8 @@ function findDefineComponentMetaList( const walk = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const callee = node.expression.getText(sf); - // 无参调用时 arguments[0] 是 undefined,而 TS 把 Node 索引结果的类型 - // 标成非空,isXxx 系列运行时并不兜底,必须先显式判空 + // 无参调用时 arguments[0] 是 undefined,而 TS 把 Node 索引结果的类型标成非空, + // isXxx 系列运行时并不兜底,必须先显式判空 const first: ts.Node | undefined = node.arguments[0]; if ( callee.includes(DEFINE_COMPONENT) && @@ -54,8 +48,8 @@ function pickProperty( } /** - * 取组件真正的主模板函数(`template: function Xxx_Template(rf, ctx) {...}`)。 - * 必须是函数,不能是字符串——选择器版本就是在这里拿到了 StringLiteral 还没察觉。 + * 取组件真正的主模板函数。必须是函数,不能是字符串——选择器版本就是在这里拿到了 + * StringLiteral 还没察觉。 */ function getTemplateFunction( sf: ts.SourceFile, @@ -82,8 +76,7 @@ function isRfBitTest(expr: ts.Expression, bit: number): boolean { } /** - * 在模板函数体的**顶层**找 `if (rf & bit) { ... }` 的块。 - * 只扫顶层,不递归进嵌套的嵌入式模板函数(Foo_div_1_Template 那种), + * 在模板函数的顶层找 `if (rf & bit) { ... }` 的块。只扫顶层,不递归进嵌套的嵌入式模板函数, * 否则会命中别人的 rf & 1 / rf & 2。 */ function findTopLevelRfBlock( @@ -127,8 +120,7 @@ export function changeComponent(data: string) { if (componentName) { componentNames.push(componentName); } - // 空模板(`template: function X_Template(rf, ctx) {}`)没有 rf & 1, - // 没有可注入的位置,跳过 + // 空模板没有 rf & 1,没有可注入的位置,跳过 const initBlock = findTopLevelRfBlock(templateFn, 1); if (!initBlock) { continue; @@ -148,9 +140,8 @@ export function changeComponent(data: string) { 'end', ); } else { - // 分支 B:没有更新块(或更新块是空的——老逻辑取 statements[length - 1] - // 会得到 undefined,在 insertNode 里读 getStart 直接崩), - // 在 init 块后面补一个完整的 + // 分支 B:没有更新块(或更新块是空的——取 statements[length - 1] 会得到 undefined, + // 在 insertNode 里读 getStart 直接崩),在 init 块后面补一个完整的 updateInsertChange = change.insertNode( initBlock, `if(rf & 2){${updateContent}}`, @@ -162,14 +153,12 @@ export function changeComponent(data: string) { } if (!injectedCount) { - // 没有注入点也要按原契约返回 { content, componentName }: - // SetupComponentDataService 靠返回值是否为 undefined 决定要不要产出组件元数据, - // 这里提前 return undefined 会让空模板组件(如 TestLibraryComponent) - // 整个不产出。只有「本文件根本没有组件」才返回 undefined。 + // 没有注入点也要按原契约返回 { content, componentName }:调用方靠返回值是否为 undefined + // 决定要不要产出组件元数据,这里提前 return undefined 会让空模板组件整个不产出。 + // 只有「本文件根本没有组件」才返回 undefined。 changeList.length = 0; } else { - // import 只需要一份。放在循环里 push 会给每个组件都加一遍, - // 同文件多组件时就是重复的 `import * as amp ...`。 + // import 只需要一份。放在循环里 push 会给每个组件都加一遍 changeList.unshift( new InsertChange(0, `import * as ampNgCore from '@angular/core';\n`), ); @@ -182,20 +171,13 @@ export function changeComponent(data: string) { content: RawUpdater.update(data, changeList), /** 本文件所有组件的类名,顺序与源文件中 ɵɵdefineComponent 出现顺序一致 */ componentNames, - /** - * @deprecated 用 componentNames。保留是为了不破坏已有调用方, - * 等于 componentNames[0]。 - */ + /** @deprecated 用 componentNames。等于 componentNames[0]。 */ componentName: componentNames[0] ?? '', }; } /** - * 只检测本文件里的组件类名,**不改代码**。 - * - * 库构建侧现在只需要「这个文件有哪些组件」来定位元数据,不再往产物里注 - * `amp.propertyChange` —— 那个注入已移到主构建 - * (`vite/plugins/component-transform.plugin.ts`),库产物保持 vanilla。 + * 只检测本文件里的组件类名,不改代码。库产物保持 vanilla,注入在主构建做。 */ export function detectComponentNames(data: string): string[] { const sf = ts.createSourceFile('', data, ts.ScriptTarget.Latest, true); diff --git a/src/builder/component-template-inject/multi-component-per-file.build.spec.ts b/src/builder/component-template-inject/multi-component-per-file.build.spec.ts index a8b98464..e25d26dc 100644 --- a/src/builder/component-template-inject/multi-component-per-file.build.spec.ts +++ b/src/builder/component-template-inject/multi-component-per-file.build.spec.ts @@ -16,16 +16,9 @@ import { PlatformType } from '../platform/platform'; import { runViteBuilder as runBuilder } from '../vite'; /** - * 同文件多组件支持。 - * - * 小程序侧「一个组件 = 一个 js = 一次 Component() 调用」,所以合法形态是 - * 一个组件源文件导出多个组件,各自用独立 entry 注册。 - * - * 改造前实测(两个组件共用源文件 + 两个独立 entry): - * - 先编译的那个组件模板彻底丢失(A 的 wxml 数 = 0) - * - 所有 import 该文件的 entry 都渲染成最后编译的那个组件 - * 原因就是 outputContent / useComponentPath / style 三个 map 按源文件路径做 key, - * 后写的覆盖先写的。现在 key 改成 `源文件#组件类名`。 + * 同文件多组件支持。小程序侧「一个组件 = 一个 js = 一次 Component() 调用」,所以合法形态是 + * 一个组件源文件导出多个组件,各自用独立 entry 注册。key 用 `源文件#组件类名`, + * 只按源文件路径做 key 会让后写的覆盖先写的。 */ const TWO_COMPONENT_TS = ` @@ -63,7 +56,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { it('两个组件各自产出独立模板,且不会互相串', async () => { const angularConfig = { ...DEFAULT_ANGULAR_CONFIG, - platform: PlatformType.wx, + platform: PlatformType.wx as const, sourceMap: false, }; const root = harness.host.root(); @@ -115,7 +108,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { const aWxml = await wxmlOf('entry-a'); const bWxml = await wxmlOf('entry-b'); - // A 的模板必须存在(改造前这里直接是 0,内容整个丢了) + // A 的模板必须存在 expect(aWxml).toContain('A-input'); expect(aWxml).not.toContain('B-input'); diff --git a/src/builder/control-flow.spec.ts b/src/builder/control-flow.spec.ts index 3ce82303..61e53461 100644 --- a/src/builder/control-flow.spec.ts +++ b/src/builder/control-flow.spec.ts @@ -14,13 +14,12 @@ import { ALL_PAGE_NAME_LIST, } from '../../test/util/file'; import { executeOnceShared } from '../../test/util/shared-build'; -// 主测试链路已切到 Vite builder(webpack 链路待删除) import { PlatformType } from './platform/platform'; import { runViteBuilder as runBuilder } from './vite'; const angularConfig = { ...DEFAULT_ANGULAR_CONFIG, - platform: PlatformType.wx, + platform: PlatformType.wx as const, sourceMap: false, }; @@ -112,8 +111,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { parseInt(m[1], 10), ), ); - // bundler 无关:webpack 产出 `["ɵɵrepeaterCreate"](11,`, - // Vite/Rolldown 产出 `ɵɵrepeaterCreate(11,`。 + // bundler 无关:webpack 产出 `["ɵɵrepeaterCreate"](11,`,Vite/Rolldown 产出 `ɵɵrepeaterCreate(11,`。 // `(?:"])?` 让中间那段 webpack 包装变成可选。 const repeaters = [ ...compiled.matchAll(/repeaterCreate(?:"])?\((\d+),/g), @@ -137,13 +135,9 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { const { wxml, compiled } = await load(); const anchors = collectAnchorsFromWxml(wxml); - // 不能用 `repeaterCreate"]\((\d+),` 这种正则:那是 webpack 的格式化 - // 产物。Vite/Rolldown 会把调用重命名成短别名(`At(10, ...)`), - // 按名字找不到,于是 repeaters 恒为空,断言退化成 - // 「Set(11,14) 等于 Set()」而失败。 - // - // 改用**模板名**推导——Angular 生成的模板函数名自带索引, - // 且不受 bundler 影响: + // 不能用 `repeaterCreate"]\((\d+),` 这种正则:那是 webpack 的格式化产物。Vite/Rolldown 会把调用 + // 重命名成短别名,按名字找不到,于是 repeaters 恒为空,断言退化成「Set(11,14) 等于 Set()」而失败。 + // 改用模板名推导——Angular 生成的模板函数名自带索引,且不受 bundler 影响: // ControlFlowComponent_For_11_Template 主模板 // ControlFlowComponent_ForEmpty_12_Template @empty 模板 const expectedMain = new Set( diff --git a/src/builder/library-meta-sidecar.spec.ts b/src/builder/library-meta-sidecar.spec.ts index 31265a6d..35435084 100644 --- a/src/builder/library-meta-sidecar.spec.ts +++ b/src/builder/library-meta-sidecar.spec.ts @@ -2,15 +2,12 @@ * 库元数据 sidecar(`<库根>/mp-library-meta.json`)→ wxml 事件绑定。 * * 钉住两条相反的方向,缺一不可: - * * ✅ sidecar 里有正确的 host 元数据,并且真的驱动了 wxml 的 bind:* 事件 - * ❌ `.d.ts` 里**不再**出现 `declare const X_Listeners` 这类内联标记 + * ❌ `.d.ts` 里不出现 `declare const X_Listeners` 这类内联标记 * - * 第二条是这次重构的核心承诺:`.d.ts` 是类型契约,不当 key-value 存储用。 - * 旧方案之所以要「构建后补写」,是因为 ng-packagr 22 的 d.ts 扁平化会把 - * 未导出的 `declare const` tree-shake 掉;标记一丢,应用侧拿到空 listeners - * 并覆盖掉 host.listeners,wxml 一个事件绑定都没有,且毫无报错。 - * 元数据搬到 sidecar 之后,扁平化根本碰不到它。 + * `.d.ts` 是类型契约,不当 key-value 存储用:ng-packagr 的 d.ts 扁平化会把 + * 未导出的 `declare const` tree-shake 掉,标记一丢,应用侧拿到空 listeners + * 并覆盖掉 host.listeners,且毫无报错。元数据搬到 sidecar 后扁平化碰不到它。 */ import { join, normalize } from '@angular-devkit/core'; import * as fs from 'fs'; @@ -40,7 +37,7 @@ import { runViteBuilder as runBuilder } from './vite'; const angularConfig = { ...DEFAULT_ANGULAR_CONFIG, - platform: PlatformType.wx, + platform: PlatformType.wx as const, sourceMap: false, }; @@ -70,25 +67,18 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { await h.moveDir(ALL_COMPONENT_NAME_LIST, '__components', 'components'); await h.addPageEntry(ALL_PAGE_NAME_LIST); - // 本 harness 的 builder 是 **app** builder,跑不了 library target, - // 所以 test-library 由 `library/library.spec.ts` 构建后拷进 - // `test/hello-world-app/node_modules/test-library`,这里读那份副本。 - // 谁先跑由 `vitest.config.mts` 的 sequencer 保证,不在脚本里排序。 - // - // 但单独跑本文件(`vitest run library-meta-sidecar`)时 sequencer 帮不上忙, - // 副本可能缺失或是上一轮残留,所以这里显式验新鲜度: + // 本 harness 的 builder 是 app builder,跑不了 library target,所以 test-library + // 由 `library/library.spec.ts` 构建后拷进 node_modules,这里读那份副本。 + // 单独跑本文件时 sequencer 帮不上忙,所以这里显式验新鲜度: // 宁可大声报错,也不要静默地测一个旧副本而给出假绿灯。 - // - // 用 devkit host 读,不用 `fs` + host.root():root 是虚拟路径 - // `/C/code/...`,Windows 下 Node 的 fs 解析不了。 + // 用 devkit host 读,不用 `fs`:root 是虚拟路径,Windows 下 Node 的 fs 解析不了。 const installedLibEntry = join( normalize(root), 'node_modules/test-library/fesm2022/test-library.mjs', ); let installedLib: string; try { - // host.read() 发的是 ArrayBuffer,`fileBufferToString` 不认(会变成 - // "[object ArrayBuffer]"),所以走 Buffer.from。 + // host.read() 发的是 ArrayBuffer,`fileBufferToString` 不认,所以走 Buffer.from installedLib = Buffer.from( await harness.host.read(installedLibEntry).toPromise(), ).toString('utf8'); @@ -166,10 +156,8 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { 'utf8', ), /** - * app 产物里的全部 JS。 - * - * 必须在 `load()` 里就读完:harness 的临时工程目录会在 spec 之间被 - * 清掉,拿着 `base` 稍后再去 scandir 就是 ENOENT。 + * app 产物里的全部 JS。必须在 `load()` 里就读完:harness 的临时工程目录 + * 会在 spec 之间被清掉,拿着 `base` 稍后再去 scandir 就是 ENOENT。 */ allAppJs: (() => { const out: { name: string; content: string }[] = []; @@ -196,10 +184,8 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { }); /** - * 读 app 产物里的全部 JS。 - * - * 用来验证「库组件的 `amp.propertyChange` 是主构建注进去的」—— - * 库自己的 fesm 里没有(vanilla),但 app 的 chunk 里有。 + * 读 app 产物里的全部 JS。用来验证库组件的 `amp.propertyChange` 是主构建注进去的—— + * 库自己的 fesm 里没有。 */ const readAllAppJs = async () => (await load()).allAppJs; @@ -243,7 +229,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { const meta = await readMeta(); const directives = meta.entries['types/angular-miniprogram-forms.d.ts'].directives; - // 监听器 / 输入属性的**集合**要一致,但声明顺序无关,所以两边都排序。 + // 监听器 / 输入属性的集合要一致,但声明顺序无关,所以两边都排序 expect([...directives.DefaultValueAccessor.listeners].sort()).toEqual([ 'bindblur', 'bindinput', @@ -290,8 +276,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { }); it('反向对照:断言不是恒真(wxml 事件全部能追溯到 sidecar 元数据)', async () => { - // 把 sidecar 里的事件名当成集合,与 wxml 中出现的事件一一对应。 - // 若事件是别处硬编码来的,这个对应关系就不会成立。 + // 把 sidecar 里的事件名当成集合,与 wxml 中出现的事件一一对应。若事件是别处硬编码来的,这个对应关系就不会成立 const meta = await readMeta(); const { wxml } = await load(); @@ -324,9 +309,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { /** * 专用 demo 页 `pages/library-meta-demo`:整页都是 test-library 调用。 - * - * 源模板(src/__pages/library-meta-demo/library-meta-demo.component.html) - * 与 sidecar 记录的对应关系,逐字段钉住。 + * 源模板与 sidecar 记录的对应关系,逐字段钉住。 */ describe('库元数据 demo 页(test-library 调用)', () => { it('库指令 TestLibraryDirective 的 listeners → bind:tap / bind:touchstart', async () => { @@ -372,7 +355,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { it('库组件自身产物 wxml 不再是空 block(模板真的编译进去了)', async () => { const { libCompWxml } = await load(); - // 改之前:``,空的,肉眼无法判断渲染了没有。 + // 空 block 与有子节点的 block 肉眼无法判断渲染了没有 expect(libCompWxml).not.toMatch( /<\/block>/, ); @@ -382,8 +365,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { it('库组件模板文本走运行时数据,wxml 里是 {{nodeList[N].value}}', async () => { const { libCompWxml } = await load(); - // 说明:文本节点不进 wxml 字面量,而是存在 vnode 里由运行时填。 - // 所以“渲染标记在不在”不能靠 wxml 字面量查,得看 JS 产物 + wxss。 + // 文本节点不进 wxml 字面量,而是存在 vnode 里由运行时填,所以不能靠 wxml 字面量查 expect(libCompWxml).toContain('{{nodeList[1].value}}'); }); @@ -393,12 +375,9 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { }); /** - * input / output 传值。 - * - * 实测:`` 生成的 wxml 与不传时**一模一样**, - * input 不以任何属性形式出现在 wxml 里(它走 vnode,运行时由组件自己读)。 - * 所以这部分只能查编译后的 JS:Angular 会把 input/output 名保留成字符串 - * 字面量(consts 里的 `[3, 'input1']`、update 里的 `property('input1', ...)`)。 + * input / output 传值。`` 生成的 wxml 与不传时一模一样 + * (input 走 vnode,运行时由组件自己读),所以这部分只能查编译后的 JS: + * Angular 会把 input/output 名保留成字符串字面量。 */ it('库组件 input 传值进了编译产物', async () => { const { demoJs } = await load(); @@ -426,9 +405,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { }); it('反向对照:demo 页的 bind:* 事件全部在 test-library 的 sidecar 里有记录', async () => { - // 注意:demo 页用的是 **test-library(第二个库)**,不是主库 - // angular-miniprogram。所以这里读的是 test-library 自己的 - // mp-library-meta.json(由 library.spec.ts 构建后拷进 node_modules)。 + // demo 页用的是 test-library(第二个库),不是主库,所以读它自己的 mp-library-meta.json const libMetaPath = path.resolve( __dirname, '../../test/hello-world-app/node_modules/test-library/mp-library-meta.json', @@ -469,14 +446,8 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { }); /** - * 运行时 hook 的所有权:库不管,主构建管。 - * - * 重构前:库构建把 `import * as amp` + `amp.propertyChange(...)` 烘进 - * 自己的 fesm,库产物不是 vanilla,且运行时 hook 与库版本死锁。 - * 重构后:库只出 `mp-library-meta.json`,主构建读到 sidecar 后给库组件 - * 补上注入。 - * - * 两个方向都要钉: + * 运行时 hook 的所有权:库不管,主构建管。库只出 `mp-library-meta.json`, + * 主构建读到 sidecar 后给库组件补上注入。两个方向都要钉: * ✅ app chunk 里库组件的 template fn 有且仅有一次 propertyChange * ✅ 库 fesm 本身一个 propertyChange 都没有(证明注入来自 app 侧) */ @@ -583,13 +554,8 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { /** * 二级出口(`test-library/src/secondary`)走主构建。 - * - * 多 entry point 的库,每个 entry 有自己的 fesm、自己的组件。主构建 - * 必须按**组件名**把两边分别对上:碰二级 fesm 只处理二级组件,碰一级 - * fesm 只处理一级组件。 - * - * 重构前这里是 `meta.entry ? [meta.entry] : meta.entries`,碰不到 entry - * 就把整包 emit 一遍 —— 多出口包下会把应用根本没 import 的出口也产出来。 + * 多 entry point 的库,每个 entry 有自己的 fesm、自己的组件。主构建必须按组件名 + * 把两边分别对上:碰二级 fesm 只处理二级组件,碰一级 fesm 只处理一级组件。 */ describe('二级出口在主构建下', () => { it('二级出口组件产出自己的 wxml,内容只含它自己的模板', async () => { diff --git a/src/builder/library-multiplatform.spec.ts b/src/builder/library-multiplatform.spec.ts index 545ee6f9..d9066d4c 100644 --- a/src/builder/library-multiplatform.spec.ts +++ b/src/builder/library-multiplatform.spec.ts @@ -1,28 +1,16 @@ /** * 多平台:一份库产物,通吃非 wx 平台。 * - * 这是 schemaVersion 3 架构的核心卖点,之前完全没测: + * 库构建产出的是 `${}` 插值模板串(平台中立):平台相关处写成 `${directivePrefix}` / + * `${eventListConvert(["tap"])}`,wxml 自己的 `{{hasLoad}}` 是静态文本原样进出; + * 填充发生在主构建,用目标平台的 `LibraryTemplateValues` 跑 `renderLibraryTemplate()`。 + * 所以库只需要构建一次,平台相关的东西一个都不烘进库里。 * - * 库构建产出的是 **`${}` 插值模板串**(平台中立):平台相关处写成 - * `${directivePrefix}` / `${eventListConvert(["tap"])}`,wxml 自己的 - * `{{hasLoad}}` 是静态文本原样进出;填充发生在主构建,用目标平台的 - * `LibraryTemplateValues` 跑 `renderLibraryTemplate()`。 + * 这里用 zfb(支付宝)做对照,因为它跟 wx 的差异足够大:指令前缀 `a:if`、事件名 `onTap`、 + * 模板 `.axml`、样式 `.acss`。 * - * 所以库只需要构建一次,wx / zfb / bd / qq 各自的主构建把同一份 `content` - * 填成各自平台的 wxml + 事件名 + 文件扩展名。平台相关的东西一个都不烘进库里。 - * - * 这里用 zfb(支付宝)做对照,因为它跟 wx 的差异足够大、一眼能看出来: - * - * | 维度 | wx | zfb | - * | ---------- | ----------- | ---------- | - * | 指令前缀 | `wx:if` | `a:if` | - * | 事件名 | `bind:tap` | `onTap` | - * | 模板扩展名 | `.wxml` | `.axml` | - * | 样式扩展名 | `.wxss` | `.acss` | - * - * 单独开一个 spec 文件而不是塞进 `library-meta-sidecar.spec.ts`:那个文件里 - * wx 的 `load()` 是 memoize 的,再跑一次 zfb 构建会把同一批 `.js` chunk - * 覆盖掉,两个平台的断言会互相污染。 + * 单独开一个 spec 文件而不是塞进 `library-meta-sidecar.spec.ts`:那个文件里 wx 的 `load()` 是 + * memoize 的,再跑一次 zfb 构建会把同一批 `.js` chunk 覆盖掉,两个平台的断言会互相污染。 */ import { join, normalize } from '@angular-devkit/core'; import * as fs from 'fs'; @@ -69,8 +57,7 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { await h.moveDir(ALL_COMPONENT_NAME_LIST, '__components', 'components'); await h.addPageEntry(ALL_PAGE_NAME_LIST); - // 同 library-meta-sidecar.spec.ts:app harness 跑不了 library target, - // test-library 靠外部先构建,这里验一下副本新鲜度,避免假绿灯。 + // app harness 跑不了 library target,test-library 靠外部先构建,这里验一下副本新鲜度,避免假绿灯 const installedLib = Buffer.from( await harness.host .read( @@ -87,11 +74,10 @@ describeBuilder(runBuilder, BROWSER_BUILDER_INFO, (harness) => { '先跑 `vitest run src/builder/library/library.spec.ts` 重新生成', ).toContain('LIB_TEST_LIBRARY_RENDERED'); - // 关键:库**不重新构建**,只是主构建换了平台。 - // 库产物(含 sidecar)与 wx 那次用的是同一份。 + // 关键:库不重新构建,只是主构建换了平台。库产物(含 sidecar)与 wx 那次用的是同一份。 const angularConfig = { ...DEFAULT_ANGULAR_CONFIG, - platform: PlatformType.zfb, + platform: PlatformType.zfb as const, sourceMap: false, }; harness.useTarget('build', angularConfig); diff --git a/src/builder/library/add-declaration-metadata.service.ts b/src/builder/library/add-declaration-metadata.service.ts index d6647151..a371d765 100644 --- a/src/builder/library/add-declaration-metadata.service.ts +++ b/src/builder/library/add-declaration-metadata.service.ts @@ -16,16 +16,8 @@ import { ENTRY_POINT_TOKEN } from './token'; /** * 把本 entry 编译期拿到的指令 / 组件 host 元数据登记进 sidecar 暂存区。 - * - * **不再改写 `.d.ts`。** - * - * 旧实现在这里 `return data + markers`,把 `declare const X_Listeners:[...]` - * 拼到中间 d.ts 尾巴上;因为 ng-packagr 22 的 d.ts 扁平化会把这些不在导出 - * 引用图里的 `declare const` tree-shake 掉,还得在构建后再补写一次到最终 - * `dist/types/*.d.ts`。等于把类型契约文件当 key-value 存储,还要跟打包器搏斗。 - * - * 现在 `.d.ts` 原样返回,元数据走 `mp-library-meta.json` sidecar, - * 扁平化爱怎么 shake 怎么 shake,与我们无关。 + * 不改写 `.d.ts`:`.d.ts` 是类型契约,不当 key-value 存储用。元数据走 + * `mp-library-meta.json` sidecar,扁平化怎么处理与我们无关。 */ export class AddDeclarationMetaDataService { private entryPoint = inject(ENTRY_POINT_TOKEN); @@ -39,10 +31,7 @@ export class AddDeclarationMetaDataService { this.componentMap = miniProgramCompilerService.getComponentMap(); } - /** - * 扫描本文件产出的 d.ts,把里面的指令/组件登记进暂存区, - * 然后**原样返回**内容。 - */ + /** 扫描本文件产出的 d.ts,把里面的指令/组件登记进暂存区,然后原样返回内容。 */ run(_dTsFileName: string, data: string): string { const list = createCssSelectorForTs(data).queryAll( `ClassDeclaration`, @@ -71,8 +60,7 @@ export class AddDeclarationMetaDataService { private recordDirectives(list: ts.ClassDeclaration[]) { for (const classDeclaration of list) { - // 组件的 `ɵcmp` 里也带 `ɵdir`,但组件已在上面登记过, - // 这里只处理纯指令,避免同一个类进两个桶。 + // 组件的 `ɵcmp` 里也带 `ɵdir`,但组件已在上面登记过,这里只处理纯指令,避免同一个类进两个桶 if (hasStaticMember(classDeclaration, 'ɵcmp')) { continue; } diff --git a/src/builder/library/builder.ts b/src/builder/library/builder.ts index 40ef71e2..9e3bd49c 100644 --- a/src/builder/library/builder.ts +++ b/src/builder/library/builder.ts @@ -50,8 +50,7 @@ export function execute( const ngPackagrOptions = { cacheEnabled, cacheDirectory: join(cacheDirectory, 'ng-packagr'), - // ng-packagr 自己把它交给 chokidar 的 watchOptions.poll, - // 网络盘 / WSL 挂载上没轮询就看不到改动 + // ng-packagr 自己把它交给 chokidar 的 watchOptions.poll,网络盘 / WSL 挂载上没轮询就看不到改动 poll: options.poll, }; diff --git a/src/builder/library/compile-ngc.transform.ts b/src/builder/library/compile-ngc.transform.ts index 66e81dc8..b04ddd4d 100644 --- a/src/builder/library/compile-ngc.transform.ts +++ b/src/builder/library/compile-ngc.transform.ts @@ -69,7 +69,7 @@ export const myCompileNgcTransformFactory = ( basePath, cssUrl, styleIncludePaths, - // ng-packagr 19 在 cacheDirectory 之前新增了 `sass` 参数 + // ng-packagr 在 cacheDirectory 之前新增了 `sass` 参数 undefined, options.cacheEnabled && options.cacheDirectory, options.watch, @@ -77,13 +77,9 @@ export const myCompileNgcTransformFactory = ( /** * 先把本 entry 的「扁平化 d.ts 路径 / fesm 产物路径」登记进 sidecar 暂存区。 - * - * 用 ng-packagr 自己的 `declarationsBundled`(= `dist/types/.d.ts`), - * 也就是最终 `package.json#typings` 指的那个文件。读取侧拿类的 - * `getSourceFile()` 相对库根一算就能命中,不用反推 exports map。 - * - * fesm 路径同样直接拿:主构建要把「正在 transform 的那个 .mjs」对上 - * 具体 entry,多 entry point 的包才不会误伤。 + * 用 ng-packagr 自己的 `declarationsBundled`,也就是最终 `package.json#typings` 指的那个文件, + * 读取侧拿类的 `getSourceFile()` 相对库根一算就能命中,不用反推 exports map。 + * fesm 路径同样直接拿:主构建要把正在 transform 的那个 .mjs 对上具体 entry。 */ registerLibraryMetaEntry( entryPoint.data.entryPoint.moduleId, @@ -107,10 +103,8 @@ export const myCompileNgcTransformFactory = ( ); /** - * 每个 entry 编译完就落一次盘(全量重写,幂等)。 - * - * 放在这里而不是 build 结束后的钩子上,是因为 watch 模式下 - * 「build 结束」根本不会发生;挂在 entry 编译尾部两条路径都能覆盖。 + * 每个 entry 编译完就落一次盘(全量重写,幂等)。放在这里而不是 build 结束后的钩子上, + * 是因为 watch 模式下「build 结束」根本不会发生。 */ writeLibraryMetaFile(distRoot); } catch (error) { diff --git a/src/builder/library/compile-source-files.ts b/src/builder/library/compile-source-files.ts index cdf001af..4823962f 100644 --- a/src/builder/library/compile-source-files.ts +++ b/src/builder/library/compile-source-files.ts @@ -40,10 +40,7 @@ import { RESOLVED_DATA_GROUP_TOKEN, } from './token'; -/** - * ng-packagr 21 删掉了 `src/lib/utils/load-esm`,改成直接 require compiler-cli。 - * 这里自己保留一个懒加载入口,避免在模块顶层就把 compiler-cli 拉进来。 - */ +/** 懒加载入口,避免在模块顶层就把 compiler-cli 拉进来。 */ async function ngCompilerCli() { return import('@angular/compiler-cli'); } @@ -81,8 +78,7 @@ export async function compileSourceFiles( augmentLibraryMetadata(tsCompilerHost); const cache = entryPoint.cache; const sourceFileCache = cache.sourcesFileCache; - // ng-packagr 19: Angular 诊断缓存从 FileCache 中拆出, - // 改为 entryPoint.cache.angularDiagnosticCache(get/update) + // Angular 诊断缓存从 FileCache 中拆出,改为 entryPoint.cache.angularDiagnosticCache const angularDiagnosticsCache = cache.angularDiagnosticCache; // Create the Angular specific program that contains the Angular compiler @@ -96,8 +92,8 @@ export async function compileSourceFiles( const angularCompiler = angularProgram.compiler; const { ignoreForDiagnostics, ignoreForEmit } = angularCompiler; - // SourceFile versions are required for builder programs. - // The wrapped host inside NgtscProgram adds additional files that will not have versions. + // SourceFile versions are required for builder programs. The wrapped host inside NgtscProgram + // adds additional files that will not have versions. const typeScriptProgram = angularProgram.getTsProgram(); augmentProgramWithVersioning(typeScriptProgram); @@ -125,17 +121,14 @@ export async function compileSourceFiles( const result = builder.getSemanticDiagnosticsOfNextAffectedFile( undefined, (sourceFile) => { - // If the affected file is a TTC shim, add the shim's original source file. - // This ensures that changes that affect TTC are typechecked even when the changes - // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes. - // For example, changing @Input property types of a directive used in another component's - // template. + // If the affected file is a TTC shim, add the shim's original source file. This ensures + // that changes that affect TTC are typechecked even when the changes are otherwise + // unrelated from a TS perspective and do not result in Ivy codegen changes. if ( ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts') ) { - // This file name conversion relies on internal compiler logic and should be converted - // to an official method when available. 15 is length of `.ngtypecheck.ts` + // 依赖内部编译逻辑:15 是 `.ngtypecheck.ts` 的长度 const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts'; const originalSourceFile = builder.getSourceFile(originalFilename); if (originalSourceFile) { @@ -192,22 +185,17 @@ export async function compileSourceFiles( ], }); const miniProgramCompilerService = injector.get(MiniProgramCompilerService); - // Required to support asynchronous resource loading - // Must be done before creating transformers or getting template diagnostics + // Required to support asynchronous resource loading. Must be done before creating transformers + // or getting template diagnostics await angularCompiler.analyzeAsync(); // inject miniProgramCompilerService.init(); const metaMap = await miniProgramCompilerService.exportComponentBuildMetaMap(); /** - * 把内联样式(`@Component.styles` / 模板 `