Skip to content

Commit f920112

Browse files
author
LessJS CI
committed
fix(ssg): SOP-016 — self-contained HTMLElement stub + Map-backed customElements
Root cause analysis: - output.banner's bare class HTMLElement {} had zero DOM methods, causing 569 DSD errors (hasAttribute/getAttribute not functions) - customElements polyfill used no-op define()/get(), causing renderDSDByName() to return void elements for all components Fixes: - packages/core/src/dsd-element.ts: Replace bare class {} with _SsrHTMLElementStub (6 methods: hasAttribute, getAttribute, setAttribute, removeAttribute, tagName, isConnected). Assign to globalThis.HTMLElement. @lessjs/core is now self-contained. - packages/adapter-vite/src/cli/build-ssg.ts: Replace no-op customElements with Map-backed implementation (define stores, get returns). Remove HTMLElement from banner. - packages/content/deno.json: Add section-matter transitive dependency. - docs: SOP-016 document, ADR-0044 update, CHANGELOG v0.21.13 Verified: - deno test cli.test.ts: 15/15 passed - deno test ssg-smoke.test.ts: 1/1, 4/4 steps passed - dsd:check-report: UNKNOWN errors 449→0 - deno fmt --check: 538 files clean
1 parent 6a06a95 commit f920112

10 files changed

Lines changed: 257 additions & 123 deletions

deno.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/adr/ADR-0042-import-map-universal-resolution.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ LessJS SSG 构建管线 Phase 3 当前使用 `viteBuild({ssr:true, noExternal: [
1313
问题:Rolldown(Vite 底层打包器)在做 ESM 解析时,无法正确处理 npm 包的子路径导出(subpath exports)。具体表现为 `parse5` 内部引用 `entities/lib/escape.js` 时,Rolldown 无法通过 npm package.json 的 `exports` 字段正确解析该子路径,导致构建失败。
1414

1515
根因分析:
16+
1617
- **ESM 子路径解析是运行时(Deno/Browser)的职责**,不是打包工具的职责
1718
- Rolldown 的 npm 包解析基于 node_modules 文件系统布局,而非 package.json `exports` 语义
1819
- `noExternal` 策略强行将运行时解析责任转移给了打包工具
@@ -93,12 +94,12 @@ LessJS SSG 构建管线 Phase 3 当前使用 `viteBuild({ssr:true, noExternal: [
9394

9495
## Resolution Responsibility Boundary
9596

96-
| 组件 | 旧职责 | 新职责 |
97-
|------|--------|--------|
98-
| `deno.json` import map | 仅 LessJS workspace 开发 | **所有环境**的模块分辨率来源 |
99-
| Rolldown/Vite | 解析所有依赖(含 npm 子路径) | 仅打包 LessJS 业务代码 + Lit 生态 |
100-
| Deno ESM Runtime | 不使用(self-contained bundle) | 解析所有 external 依赖(含子路径) |
101-
| `@deno/vite-plugin` | 开发服务器 bare specifier 解析 | 消费者构建时的 Deno import map bridge |
97+
| 组件 | 旧职责 | 新职责 |
98+
| ---------------------- | ------------------------------- | ------------------------------------- |
99+
| `deno.json` import map | 仅 LessJS workspace 开发 | **所有环境**的模块分辨率来源 |
100+
| Rolldown/Vite | 解析所有依赖(含 npm 子路径) | 仅打包 LessJS 业务代码 + Lit 生态 |
101+
| Deno ESM Runtime | 不使用(self-contained bundle) | 解析所有 external 依赖(含子路径) |
102+
| `@deno/vite-plugin` | 开发服务器 bare specifier 解析 | 消费者构建时的 Deno import map bridge |
102103

103104
## Non-Goals
104105

docs/adr/ADR-0043-ssg-phase3-dependency-strategy.md

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,28 @@
1212

1313
```ts
1414
const defaultNoExternal = [
15-
/^lit/, /^@lit/, /^@lessjs\/ui/, /^@lessjs\/adapter-lit/,
16-
'parse5', 'entities', 'node-fetch', 'fetch-blob',
17-
'data-uri-to-buffer', 'formdata-polyfill', 'domexception', 'node-domexception',
15+
/^lit/,
16+
/^@lit/,
17+
/^@lessjs\/ui/,
18+
/^@lessjs\/adapter-lit/,
19+
'parse5',
20+
'entities',
21+
'node-fetch',
22+
'fetch-blob',
23+
'data-uri-to-buffer',
24+
'formdata-polyfill',
25+
'domexception',
26+
'node-domexception',
1827
];
1928
```
2029

2130
这个策略将所有依赖内联到单一 SSR bundle 中。动机是:
31+
2232
1. 生成自包含 bundle,方便 Deno Deploy 部署
2333
2. 确保模块级变量(Phase B)在整个图中共享
2434

2535
问题:
36+
2637
1. **Rolldown 子路径解析失败**`parse5` 内部引用 `entities/lib/escape.js`,Rolldown 无法正确解析
2738
2. **bundle 膨胀**:node-fetch、fetch-blob 等 Deno 已有原生实现的包被冗余打包
2839
3. **维护成本**:每个新增 SSR 依赖都需要手动添加到 `noExternal` 列表
@@ -35,16 +46,17 @@ const defaultNoExternal = [
3546
### 第一层:noExternal — LessJS 业务代码 + Lit 生态
3647

3748
这些包由 Rolldown 打包进 SSR bundle,因为它们需要:
49+
3850
- TypeScript 编译(Lit decorators)
3951
- 模块级变量共享(Phase B 单例)
4052
- Tree-shaking 和 dead code elimination
4153

4254
```ts
4355
const ssrNoExternal = [
44-
/^@lessjs\//, // 所有 LessJS 框架包
45-
/^lit/, // lit, lit-html, lit-element
46-
/^@lit/, // @lit/reactive-element, @lit-labs/ssr-dom-shim
47-
/^@lit-labs\//, // @lit-labs/* (ssr-dom-shim 等)
56+
/^@lessjs\//, // 所有 LessJS 框架包
57+
/^lit/, // lit, lit-html, lit-element
58+
/^@lit/, // @lit/reactive-element, @lit-labs/ssr-dom-shim
59+
/^@lit-labs\//, // @lit-labs/* (ssr-dom-shim 等)
4860
];
4961
```
5062

@@ -54,12 +66,12 @@ const ssrNoExternal = [
5466

5567
```ts
5668
const ssrExternal = [
57-
'parse5', // HTML parser — 有子路径导出
58-
'entities', // HTML entity codec — 有子路径导出
59-
'hono', // HTTP framework
60-
'hono/*', // Hono subpath exports
61-
'node-fetch', // Deno 有原生 fetch
62-
'fetch-blob', // Deno 有原生 Blob
69+
'parse5', // HTML parser — 有子路径导出
70+
'entities', // HTML entity codec — 有子路径导出
71+
'hono', // HTTP framework
72+
'hono/*', // Hono subpath exports
73+
'node-fetch', // Deno 有原生 fetch
74+
'fetch-blob', // Deno 有原生 Blob
6375
'data-uri-to-buffer',
6476
'formdata-polyfill',
6577
'domexception',
@@ -71,13 +83,13 @@ const ssrExternal = [
7183

7284
判断一个依赖属于哪一层的规则:
7385

74-
| 条件 | 层级 | 理由 |
75-
|------|------|------|
76-
| `@lessjs/*`| `noExternal` | 需要 TypeScript 编译 + Phase B 单例共享 |
77-
| `lit` / `@lit/*` 生态 | `noExternal` | 需要 decorator 编译 + Lit SSR 内部状态 |
78-
| npm 包有子路径导出且被传递依赖引用 | `external` | Rolldown 无法解析 → 交还 Deno |
79-
| npm 包在 Deno 有原生替代 | `external` | 避免冗余打包 |
80-
| npm 纯 JS 包无子路径依赖 | `external`(默认) | 减少 Rolldown 负担 |
86+
| 条件 | 层级 | 理由 |
87+
| ---------------------------------- | ------------------ | --------------------------------------- |
88+
| `@lessjs/*` | `noExternal` | 需要 TypeScript 编译 + Phase B 单例共享 |
89+
| `lit` / `@lit/*` 生态 | `noExternal` | 需要 decorator 编译 + Lit SSR 内部状态 |
90+
| npm 包有子路径导出且被传递依赖引用 | `external` | Rolldown 无法解析 → 交还 Deno |
91+
| npm 包在 Deno 有原生替代 | `external` | 避免冗余打包 |
92+
| npm 纯 JS 包无子路径依赖 | `external`(默认) | 减少 Rolldown 负担 |
8193

8294
## Architecture
8395

docs/adr/ADR-0044-ssr-browser-api-polyfill.md

Lines changed: 41 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@
1111
Web Component 代码在模块顶层执行 `customElements.define()``class Foo extends HTMLElement``new CSSStyleSheet()` 等浏览器 API。SSG/SSR 构建环境(Node.js via Vite)和运行时(Deno)不提供这些浏览器原生 API。
1212

1313
当前处理方式:
14+
1415
- **CSSStyleSheet**:在 SSG entry code 顶部 `import { StyleSheet } from '@lessjs/core'` 后手动 shim(`build-ssg.ts` L274-280)
15-
- **HTMLElement**:通过 Rolldown output banner 注入 `@lit-labs/ssr-dom-shim` polyfill(L405-407
16+
- **HTMLElement**:通过 `@lessjs/core/dsd-element.ts``_SsrHTMLElementStub` 自包含([SOP-016]
1617
- **customElements****未处理** — 这是当前导致 SSR 崩溃的直接原因
1718

1819
问题表现:
20+
1921
```ts
2022
// app/islands/my-counter.ts
2123
if (typeof customElements !== 'undefined' && !customElements.get(tagName)) {
22-
customElements.define(tagName, MyCounter); // ← customElements is undefined in SSR
24+
customElements.define(tagName, MyCounter); // ← customElements is undefined in SSR
2325
}
2426
```
2527

@@ -31,64 +33,42 @@ if (typeof customElements !== 'undefined' && !customElements.get(tagName)) {
3133

3234
### Polyfill 清单
3335

34-
| API | Polyfill 来源 | 注入位置 | 必要性 |
35-
|-----|-------------|---------|--------|
36-
| `CSSStyleSheet` | `@lessjs/core` StyleSheet | SSG entry code 顶部 | Lit 内部引用 CSSStyleSheet,模块顶层即需 |
37-
| `HTMLElement` | `@lit-labs/ssr-dom-shim` | SSG entry code 顶部 | `class Foo extends HTMLElement` 在模块顶层执行 |
38-
| `customElements` | 自实现轻量 shim | SSG entry code 顶部 | `customElements.define()` 在模块顶层调用 |
39-
| `document` | happy-dom(已有) | ssgRender 阶段 | DOM 操作在渲染时发生 |
40-
| `window` | happy-dom(已有) | ssgRender 阶段 | 组件的 connectedCallback 可能访问 |
36+
| API | Polyfill 来源 | 注入位置 | 必要性 |
37+
| ---------------- | ---------------------------------- | ------------------------- | ---------------------------------------------- |
38+
| `customElements` | Map-backed shim([SOP-016]| `output.banner` | `customElements.define()` 在模块顶层调用 |
39+
| `HTMLElement` | `@lessjs/core` 自包含([SOP-016]| `dsd-element.ts` 模块求值 | `class Foo extends HTMLElement` 在模块顶层执行 |
40+
| `CSSStyleSheet` | `@lessjs/core` StyleSheet | SSG entry code 顶部 | Lit 内部引用 CSSStyleSheet,模块顶层即需 |
41+
| `document` | happy-dom(已有) | ssgRender 阶段 | DOM 操作在渲染时发生 |
42+
| `window` | happy-dom(已有) | ssgRender 阶段 | 组件的 connectedCallback 可能访问 |
43+
44+
[SOP-016]: ../sop/v0.21.x/SOP-016-ssr-htmlelement-self-contained.md
4145

4246
### 注入机制
4347

44-
从分散的 polyfill(entry code + output banner)统一为单一 polyfill 模块
48+
polyfill 按执行顺序分三层([SOP-016]
4549

46-
```ts
47-
// packages/adapter-vite/src/ssr-polyfills.ts
48-
export function generateSsrPolyfillBanner(): string {
49-
return [
50-
// Layer 1: CSSStyleSheet (no dependencies)
51-
`import { StyleSheet } from '@lessjs/core';`,
52-
`if (typeof globalThis.CSSStyleSheet === 'undefined') {`,
53-
` globalThis.CSSStyleSheet = class {`,
54-
` replaceSync(_css: string) {}`,
55-
` get cssRules() { return []; }`,
56-
` };`,
57-
`}`,
58-
'',
59-
// Layer 2: HTMLElement (no dependencies)
60-
`import { HTMLElement as _SsrHTMLElement } from '@lit-labs/ssr-dom-shim';`,
61-
`if (!globalThis.HTMLElement) {`,
62-
` globalThis.HTMLElement = _SsrHTMLElement;`,
63-
`}`,
64-
'',
65-
// Layer 3: customElements (depends on HTMLElement existing)
66-
`if (typeof globalThis.customElements === 'undefined') {`,
67-
` const registry = new Map<string, typeof globalThis.HTMLElement>();`,
68-
` globalThis.customElements = {`,
69-
` define(name: string, ctor: typeof globalThis.HTMLElement) {`,
70-
` registry.set(name, ctor);`,
71-
` },`,
72-
` get(name: string) {`,
73-
` return registry.get(name);`,
74-
` },`,
75-
` whenDefined(_name: string) {`,
76-
` return Promise.resolve();`,
77-
` },`,
78-
` upgrade(_root: Node) {},`,
79-
` };`,
80-
`}`,
81-
].join('\n');
82-
}
83-
```
50+
1. **Layer 1 — `output.banner`:customElements(Map-backed)**
51+
- 最先执行,确保任何 `customElements.define()` 调用在模块图求值前可用
52+
- `define()` 存入 Map,`get()` 从 Map 取出 — `renderDSDByName()` 依赖此行为
53+
-`packages/adapter-vite/src/cli/build-ssg.ts`
54+
55+
2. **Layer 2 — `@lessjs/core/dsd-element.ts`:HTMLElement(自包含 stub)**
56+
- `_SsrHTMLElementStub` 提供 6 个成员
57+
-`typeof HTMLElement === 'undefined'` 时赋值到 `globalThis.HTMLElement`
58+
- `@lessjs/core` 不再依赖 `@lit-labs/ssr-dom-shim`
8459

60+
3. **Layer 3 — SSG entry code:CSSStyleSheet**
61+
- `import { StyleSheet } from '@lessjs/core'` 后在 entry code body 中 polyfill
62+
-`packages/adapter-vite/src/ssr-polyfills.ts`
63+
64+
````
8565
### 构建集成
8666
8767
在 `build-ssg.ts` 的 SSG entry code 生成阶段,polyfill banner 作为 entry code 的第一部分:
8868
8969
```ts
9070
const rawSsgEntryCode = generateSsrPolyfillBanner() + '\n' + generateHonoEntryCode(routes, {...});
91-
```
71+
````
9272

9373
替代原来分散在 entry code 和 output banner 中的 polyfill 代码。
9474

@@ -117,29 +97,28 @@ const rawSsgEntryCode = generateSsrPolyfillBanner() + '\n' + generateHonoEntryCo
11797

11898
Polyfill banner 通过 Vite virtual module 机制作为 entry code 的静态前缀注入。Rolldown 将其与业务代码一起打包,但 polyfill 的副作用(在 `globalThis` 上设置属性)保证在所有其他模块加载前执行。
11999

120-
## 与 Lit SSR DOM Shim 的关系
100+
## HTMLElement 自包含策略
121101

122-
`@lit-labs/ssr-dom-shim` 提供 `HTMLElement` 的 shim 实现。我们**继续使用它**作为 HTMLElement polyfill 的来源,但将其从 output banner 移到 entry code 中。这样做的好处
102+
[SOP-016] 将 HTMLElement polyfill 从 `@lit-labs/ssr-dom-shim` 迁移到 `@lessjs/core/dsd-element.ts` 自包含
123103

124-
1. **统一 polyfill 位置**所有 polyfill 在同一处管理
125-
2. **去除 output banner**output banner 机制不保证在所有 Vite 版本间行为一致
126-
3. **明确依赖关系**entry code 中的 import 顺序天然保证执行顺序
104+
1. **`@lessjs/core` 自包含**拥有 `DsdElement` 的包必须自己提供 SSR-safe 的 HTMLElement 基类
105+
2. **最小 stub**`_SsrHTMLElementStub` 只提供内部代码实际调用的 6 个方法
106+
3. **去除外部依赖**不再依赖 `@lit-labs/ssr-dom-shim` 用于核心 SSR 功能
127107

128108
## Consequences
129109

130110
### Positive
131111

132-
- **消除 customElements undefined 错误**:所有 WC 代码可在 SSR 环境安全加载
133-
- **统一 polyfill 管理**:从 3 个分散位置(entry code、output banner、@lit-labs/ssr-dom-shim)合并为 1 个模块
134-
- **明确执行顺序**:CSSStyleSheet → HTMLElement → customElements 的依赖链在 entry code 中可见
135-
- **易于扩展**:新增浏览器 API polyfill 只需在 `ssr-polyfills.ts` 中添加
136-
- **去除 output banner 依赖**:不再依赖 Rolldown 特定 API
112+
- **消除 customElements undefined 错误**:Map-backed polyfill 使 `define()`/`get()` 真正工作
113+
- **消除 DSD 渲染空白**:HTMLElement stub 提供组件 render() 所需的最小 DOM 方法
114+
- **`@lessjs/core` 自包含**:不再依赖 `@lit-labs/ssr-dom-shim` 用于核心 SSR
115+
- **最小表面**:stub 只包含 6 个成员,不模拟完整 DOM
137116

138117
### Negative
139118

140-
- **customElements shim 是轻量级的**不支持完整的 Custom Elements 生命周期(`attributeChangedCallback` 等),仅支持 `define()`/`get()` 注册
141-
- **模块顶层副作用**polyfill 在模块顶层修改 `globalThis`,如果将来需要隔离 polyfill 作用域,需要重构
142-
- **polyfill 代码进入 bundle**~1KB gzip 的额外开销(可接受)
119+
- **HTMLElement stub 是最小实现**仅提供 SSR render() 内部使用的 6 个方法,不支持完整 DOM 行为。实际 DOM 操作由 happy-dom 在 ssgRender 阶段提供
120+
- **customElements shim 轻量级**不支持完整的 Custom Elements 生命周期(`attributeChangedCallback` 等),仅支持 `define()`/`get()` 注册
121+
- **模块顶层副作用**polyfill 在模块顶层修改 `globalThis`
143122

144123
### Neutral
145124

0 commit comments

Comments
 (0)