@fullstack-webapp/document-shell 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +78 -0
- package/dist/document-shell.d.ts +58 -0
- package/dist/document-shell.js +181 -0
- package/dist/reference.d.ts +2 -0
- package/dist/reference.js +1 -0
- package/dist/safe-area-bridge.d.ts +38 -0
- package/dist/safe-area-bridge.js +226 -0
- package/dist/safe-area-profiles.d.ts +50 -0
- package/dist/safe-area-profiles.js +57 -0
- package/dist/vite.d.ts +8 -0
- package/dist/vite.js +86 -0
- package/docs/integration.md +340 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zou Guoqing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# `@fullstack-webapp/document-shell`
|
|
2
|
+
|
|
3
|
+
Document Shell lets a Vite application emit a useful, parser-visible first
|
|
4
|
+
screen before its JavaScript framework and extracted stylesheet are ready. It
|
|
5
|
+
compiles a consumer-owned shell into the final `index.html`, then performs a
|
|
6
|
+
one-shot handoff after the real application has committed.
|
|
7
|
+
|
|
8
|
+
This is build-time document projection, not server-side rendering. It does not
|
|
9
|
+
render request data, route content, or a second interactive application, and
|
|
10
|
+
it has no framework runtime dependency.
|
|
11
|
+
|
|
12
|
+
## When to use it
|
|
13
|
+
|
|
14
|
+
Use Document Shell when the gap between browser or WebClip startup and the
|
|
15
|
+
first framework paint exposes a white frame, unstable application chrome, or
|
|
16
|
+
an empty mount point. The projected shell is best suited to stable first-frame
|
|
17
|
+
structure such as:
|
|
18
|
+
|
|
19
|
+
- the application background and core layout;
|
|
20
|
+
- brand, navigation, and a bottom tab bar;
|
|
21
|
+
- a route-aware active state derived from `location.pathname`; and
|
|
22
|
+
- lightweight content skeletons whose geometry matches the runtime surface.
|
|
23
|
+
|
|
24
|
+
It is not a fit when the first frame requires request-only data, authentication
|
|
25
|
+
results, or interactive state that cannot be known while building the HTML.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pnpm add -D @fullstack-webapp/document-shell@beta
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Document Shell supports Vite 7 and 8. It emits standard ESM and declarations;
|
|
34
|
+
all public imports resolve from compiled package output.
|
|
35
|
+
|
|
36
|
+
## Minimal integration
|
|
37
|
+
|
|
38
|
+
The integration has four deliberate seams: an entry sentinel, a build-time
|
|
39
|
+
renderer, the Vite plugin, and a framework commit signal.
|
|
40
|
+
|
|
41
|
+
### 1. Reduce `index.html` to the Vite entry sentinel
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<!doctype html>
|
|
45
|
+
<script
|
|
46
|
+
type="module"
|
|
47
|
+
src="/src/main.tsx"
|
|
48
|
+
data-document-shell-entry
|
|
49
|
+
></script>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The checked-in file exists for Vite dependency discovery. Document metadata,
|
|
53
|
+
shell markup, critical CSS, and startup effects belong in `render()` so there
|
|
54
|
+
is one source of truth for the emitted document.
|
|
55
|
+
|
|
56
|
+
### 2. Return one document composition
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// document-shell.config.ts
|
|
60
|
+
import {
|
|
61
|
+
cssText,
|
|
62
|
+
htmlFragment,
|
|
63
|
+
type DocumentShellBuildContext,
|
|
64
|
+
type DocumentShellComposition,
|
|
65
|
+
} from '@fullstack-webapp/document-shell'
|
|
66
|
+
|
|
67
|
+
export function renderDocumentShell(
|
|
68
|
+
_context: DocumentShellBuildContext,
|
|
69
|
+
): DocumentShellComposition {
|
|
70
|
+
return {
|
|
71
|
+
document: {
|
|
72
|
+
lang: 'en',
|
|
73
|
+
title: 'Example',
|
|
74
|
+
head: [
|
|
75
|
+
htmlFragment(
|
|
76
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">',
|
|
77
|
+
),
|
|
78
|
+
htmlFragment('<link rel="manifest" href="/manifest.webmanifest">'),
|
|
79
|
+
htmlFragment('<meta name="theme-color" content="#111827">'),
|
|
80
|
+
],
|
|
81
|
+
appEntry: '/src/main.tsx',
|
|
82
|
+
mountId: 'root',
|
|
83
|
+
},
|
|
84
|
+
shell: {
|
|
85
|
+
html: htmlFragment(`
|
|
86
|
+
<div data-document-shell-static="true" aria-hidden="true">
|
|
87
|
+
<nav>Example</nav>
|
|
88
|
+
<main class="document-shell__skeleton"></main>
|
|
89
|
+
</div>
|
|
90
|
+
`),
|
|
91
|
+
criticalCss: [
|
|
92
|
+
cssText(`
|
|
93
|
+
html, body { margin: 0; min-height: 100%; background: #f8fafc; }
|
|
94
|
+
[data-document-shell-static] { position: fixed; inset: 0; }
|
|
95
|
+
.document-shell__skeleton { min-height: 60vh; }
|
|
96
|
+
`),
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`render(context)` is intentionally coarse. The consumer may produce its HTML
|
|
104
|
+
and CSS with plain strings, a template engine, or build-time React rendering.
|
|
105
|
+
The package does not define separate brand, navigation, route, manifest, or
|
|
106
|
+
skeleton schemas.
|
|
107
|
+
|
|
108
|
+
### 3. Add the Vite HTML pipeline
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// vite.config.ts
|
|
112
|
+
import { documentShell } from '@fullstack-webapp/document-shell/vite'
|
|
113
|
+
import { defineConfig } from 'vite'
|
|
114
|
+
|
|
115
|
+
import { renderDocumentShell } from './document-shell.config.ts'
|
|
116
|
+
|
|
117
|
+
export default defineConfig({
|
|
118
|
+
plugins: [
|
|
119
|
+
...documentShell({
|
|
120
|
+
render: renderDocumentShell,
|
|
121
|
+
runtimeHandoff: true,
|
|
122
|
+
}),
|
|
123
|
+
],
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The compiler runs before downstream `transformIndexHtml` hooks. A final build
|
|
128
|
+
gate reparses the emitted `index.html` and checks the document invariants after
|
|
129
|
+
all plugins have run.
|
|
130
|
+
|
|
131
|
+
### 4. Commit after the real shell is drawable
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
// DocumentShellHandoff.tsx
|
|
135
|
+
import { commitDocumentShellRuntime } from '@fullstack-webapp/document-shell/client'
|
|
136
|
+
import { useLayoutEffect } from 'react'
|
|
137
|
+
|
|
138
|
+
export function DocumentShellHandoff() {
|
|
139
|
+
useLayoutEffect(() => {
|
|
140
|
+
void commitDocumentShellRuntime()
|
|
141
|
+
}, [])
|
|
142
|
+
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Mount this component inside the real application shell, after its persistent
|
|
148
|
+
navigation and route fallback are present. For another framework, call the
|
|
149
|
+
same function from the equivalent post-commit hook. Repeated calls return the
|
|
150
|
+
same document-level promise; do not undo the handoff during component cleanup.
|
|
151
|
+
|
|
152
|
+
With `runtimeHandoff: true`, Document Shell converts the single extracted
|
|
153
|
+
stylesheet into a preload and records its load, error, or absolute timeout.
|
|
154
|
+
The commit waits for that gate, marks `<html data-app-ready="true">`, removes
|
|
155
|
+
the element carrying `data-document-shell-static`, and always fails open so a
|
|
156
|
+
broken stylesheet cannot leave a permanent overlay.
|
|
157
|
+
|
|
158
|
+
For the full file-by-file procedure, safe-area effect example, verification
|
|
159
|
+
checklist, and failure guide, see [Integration guide](docs/integration.md).
|
|
160
|
+
|
|
161
|
+
## Public exports
|
|
162
|
+
|
|
163
|
+
| Import | Responsibility |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `@fullstack-webapp/document-shell` | Typed HTML/CSS/script boundaries, document composition types, compiler, structural validation, and the shared-default safe-area bridge. |
|
|
166
|
+
| `@fullstack-webapp/document-shell/vite` | The Vite HTML producer, optional runtime-stylesheet gate, and final emitted-document validator. |
|
|
167
|
+
| `@fullstack-webapp/document-shell/client` | The framework-neutral, one-shot runtime handoff. |
|
|
168
|
+
| `@fullstack-webapp/document-shell/reference` | Reference-application safe-area rollout used to retain existing evidence while profiles mature; it is not a consumer profile selector. |
|
|
169
|
+
|
|
170
|
+
The current beta catalog contains no `sharedDefault` profile. Therefore the
|
|
171
|
+
root `createSafeAreaBridge()` deliberately emits an empty profile list today;
|
|
172
|
+
ordinary consumers receive no inferred reserve. This makes unsupported startup
|
|
173
|
+
geometry a visible no-op instead of silently adopting provisional values from
|
|
174
|
+
the reference application.
|
|
175
|
+
|
|
176
|
+
`./reference` is a pre-1.0 migration seam, not a second configuration model. It
|
|
177
|
+
remains only while the source application switches from its incubated package
|
|
178
|
+
to the public package. Remove the subpath after every reference profile has
|
|
179
|
+
either been promoted to `sharedDefault` or retired and the source application
|
|
180
|
+
uses the root entry; that removal may be part of a beta breaking release.
|
|
181
|
+
|
|
182
|
+
## Ownership boundary
|
|
183
|
+
|
|
184
|
+
Document Shell owns the document compiler, Vite transform order, structural
|
|
185
|
+
gates, stylesheet handoff, and the package-accepted safe-area profile catalog.
|
|
186
|
+
The consuming application owns:
|
|
187
|
+
|
|
188
|
+
- brand, navigation, active-route policy, shell markup, and critical CSS;
|
|
189
|
+
- manifest values, Apple WebClip metadata, icons, and splash assets;
|
|
190
|
+
- the framework commit point and the runtime shell's geometry;
|
|
191
|
+
- names of global CSS variables and data attributes changed by startup effects;
|
|
192
|
+
- diagnostic builds and device/video/trace evidence.
|
|
193
|
+
|
|
194
|
+
This keeps global side effects visible in the app's composition root. The
|
|
195
|
+
package compiles bounded data such as a `SafeAreaDomEffect`; it never serializes
|
|
196
|
+
an arbitrary callback with `Function#toString()`.
|
|
197
|
+
|
|
198
|
+
## Current constraints
|
|
199
|
+
|
|
200
|
+
- Runtime handoff supports one HTML entry and exactly one extracted stylesheet.
|
|
201
|
+
Zero or multiple stylesheet links fail the production build. Keep shell CSS
|
|
202
|
+
inline and let the application emit one runtime stylesheet.
|
|
203
|
+
- The projection is inert and carries `aria-hidden="true"`; accessibility and
|
|
204
|
+
interaction belong to the real application.
|
|
205
|
+
- Document Shell validates exactly one viewport meta, manifest link, title,
|
|
206
|
+
mount point, critical-shell style, static-shell marker, and module entry.
|
|
207
|
+
- The shared safe-area entry only emits profiles promoted to `sharedDefault`;
|
|
208
|
+
the current catalog has none, so the root bridge is intentionally inactive.
|
|
209
|
+
Reference-only profiles never enter ordinary consumer bytes. Consumers do
|
|
210
|
+
not choose individual device profiles or rollout maturity.
|
|
211
|
+
- The current safe-area runtime matches observable browser geometry and
|
|
212
|
+
platform signals, not marketing device names. Unsupported geometry fails
|
|
213
|
+
open and receives no reserve.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
From the FWA Kit repository root:
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
pnpm --filter @fullstack-webapp/document-shell build
|
|
221
|
+
pnpm --filter @fullstack-webapp/document-shell test
|
|
222
|
+
pnpm --filter @fullstack-webapp/document-shell typecheck
|
|
223
|
+
pnpm --filter @fullstack-webapp/document-shell pack:check
|
|
224
|
+
pnpm --filter @fullstack-webapp/document-shell test:packed-consumer
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The packed-consumer check installs the generated tarball into an isolated Vite
|
|
228
|
+
application, builds its real `index.html`, and imports every public subpath.
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
[MIT](LICENSE) © 2026 Zou Guoqing
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const documentShellReadyAttribute = "data-app-ready";
|
|
2
|
+
export declare const documentShellStaticAttribute = "data-document-shell-static";
|
|
3
|
+
export declare const documentShellRuntimeStylesheetId = "runtime-stylesheet";
|
|
4
|
+
export type DocumentShellHandoffResult = Readonly<{
|
|
5
|
+
status: 'revealed';
|
|
6
|
+
stylesheet: 'loaded' | 'error' | 'timeout' | 'absent';
|
|
7
|
+
}>;
|
|
8
|
+
export declare function commitDocumentShellRuntime(): Promise<DocumentShellHandoffResult>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const documentShellReadyAttribute = 'data-app-ready';
|
|
2
|
+
export const documentShellStaticAttribute = 'data-document-shell-static';
|
|
3
|
+
export const documentShellRuntimeStylesheetId = 'runtime-stylesheet';
|
|
4
|
+
const handoffs = new WeakMap();
|
|
5
|
+
export function commitDocumentShellRuntime() {
|
|
6
|
+
if (typeof document === 'undefined') {
|
|
7
|
+
return Promise.reject(new Error('Document shell runtime handoff requires a browser document'));
|
|
8
|
+
}
|
|
9
|
+
const existing = handoffs.get(document);
|
|
10
|
+
if (existing)
|
|
11
|
+
return existing;
|
|
12
|
+
let resolveHandoff;
|
|
13
|
+
const handoff = new Promise((resolve) => {
|
|
14
|
+
resolveHandoff = resolve;
|
|
15
|
+
});
|
|
16
|
+
handoffs.set(document, handoff);
|
|
17
|
+
const root = document.documentElement;
|
|
18
|
+
const stylesheet = document.getElementById(documentShellRuntimeStylesheetId);
|
|
19
|
+
let finished = false;
|
|
20
|
+
const pending = {};
|
|
21
|
+
root.setAttribute('data-document-shell-runtime-committed', 'true');
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
if (pending.fallbackTimer !== undefined)
|
|
24
|
+
window.clearTimeout(pending.fallbackTimer);
|
|
25
|
+
if (pending.revealFrame !== undefined)
|
|
26
|
+
window.cancelAnimationFrame(pending.revealFrame);
|
|
27
|
+
stylesheet?.removeEventListener('load', handleLoad);
|
|
28
|
+
stylesheet?.removeEventListener('error', handleError);
|
|
29
|
+
};
|
|
30
|
+
const reveal = (stylesheetStatus) => {
|
|
31
|
+
if (finished)
|
|
32
|
+
return;
|
|
33
|
+
finished = true;
|
|
34
|
+
cleanup();
|
|
35
|
+
root.setAttribute(documentShellReadyAttribute, 'true');
|
|
36
|
+
document.querySelector(`[${documentShellStaticAttribute}]`)?.remove();
|
|
37
|
+
resolveHandoff({ status: 'revealed', stylesheet: stylesheetStatus });
|
|
38
|
+
};
|
|
39
|
+
const revealAfterStylesApply = () => {
|
|
40
|
+
if (finished || pending.revealFrame !== undefined)
|
|
41
|
+
return;
|
|
42
|
+
if (pending.fallbackTimer !== undefined) {
|
|
43
|
+
window.clearTimeout(pending.fallbackTimer);
|
|
44
|
+
pending.fallbackTimer = undefined;
|
|
45
|
+
}
|
|
46
|
+
pending.revealFrame = window.requestAnimationFrame(() => reveal('loaded'));
|
|
47
|
+
};
|
|
48
|
+
function handleLoad() {
|
|
49
|
+
revealAfterStylesApply();
|
|
50
|
+
}
|
|
51
|
+
function handleError() {
|
|
52
|
+
reveal('error');
|
|
53
|
+
}
|
|
54
|
+
if (!stylesheet) {
|
|
55
|
+
reveal('absent');
|
|
56
|
+
return handoff;
|
|
57
|
+
}
|
|
58
|
+
if (stylesheet.dataset.loaded === 'true') {
|
|
59
|
+
reveal('loaded');
|
|
60
|
+
return handoff;
|
|
61
|
+
}
|
|
62
|
+
if (stylesheet.dataset.failure === 'error') {
|
|
63
|
+
reveal('error');
|
|
64
|
+
return handoff;
|
|
65
|
+
}
|
|
66
|
+
if (stylesheet.dataset.failure === 'timeout') {
|
|
67
|
+
reveal('timeout');
|
|
68
|
+
return handoff;
|
|
69
|
+
}
|
|
70
|
+
stylesheet.addEventListener('load', handleLoad, { once: true });
|
|
71
|
+
stylesheet.addEventListener('error', handleError, { once: true });
|
|
72
|
+
const failureDeadline = Number(stylesheet.dataset.failureDeadline);
|
|
73
|
+
const fallbackDelay = Number.isFinite(failureDeadline)
|
|
74
|
+
? Math.max(0, failureDeadline - Date.now())
|
|
75
|
+
: 0;
|
|
76
|
+
pending.fallbackTimer = window.setTimeout(() => reveal('timeout'), fallbackDelay);
|
|
77
|
+
return handoff;
|
|
78
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare const documentShellEntryMarker = "data-document-shell-entry";
|
|
2
|
+
declare const htmlFragmentBrand: unique symbol;
|
|
3
|
+
declare const cssTextBrand: unique symbol;
|
|
4
|
+
declare const inlineScriptBrand: unique symbol;
|
|
5
|
+
export type HtmlFragment = string & {
|
|
6
|
+
readonly [htmlFragmentBrand]: true;
|
|
7
|
+
};
|
|
8
|
+
export type CssText = string & {
|
|
9
|
+
readonly [cssTextBrand]: true;
|
|
10
|
+
};
|
|
11
|
+
export type InlineScript = string & {
|
|
12
|
+
readonly [inlineScriptBrand]: true;
|
|
13
|
+
};
|
|
14
|
+
export declare function htmlFragment(value: string): HtmlFragment;
|
|
15
|
+
export declare function cssText(value: string): CssText;
|
|
16
|
+
export declare function inlineScript(value: string): InlineScript;
|
|
17
|
+
export type DocumentShellBuildContext = {
|
|
18
|
+
command: 'build' | 'serve';
|
|
19
|
+
mode: string;
|
|
20
|
+
};
|
|
21
|
+
export type DocumentShellDocument = {
|
|
22
|
+
lang: string;
|
|
23
|
+
title: string;
|
|
24
|
+
head: readonly HtmlFragment[];
|
|
25
|
+
appEntry: string;
|
|
26
|
+
mountId: string;
|
|
27
|
+
};
|
|
28
|
+
export type DocumentShellProjection = {
|
|
29
|
+
html: HtmlFragment;
|
|
30
|
+
criticalCss: readonly CssText[];
|
|
31
|
+
};
|
|
32
|
+
export type DocumentShellInlineEffect = {
|
|
33
|
+
marker: string;
|
|
34
|
+
script: InlineScript;
|
|
35
|
+
};
|
|
36
|
+
export type DocumentShellStartupEffects = {
|
|
37
|
+
beforePaint?: readonly DocumentShellInlineEffect[];
|
|
38
|
+
afterShell?: readonly HtmlFragment[];
|
|
39
|
+
};
|
|
40
|
+
export type DocumentShellComposition = {
|
|
41
|
+
document: DocumentShellDocument;
|
|
42
|
+
shell: DocumentShellProjection;
|
|
43
|
+
startupEffects?: DocumentShellStartupEffects;
|
|
44
|
+
};
|
|
45
|
+
export type { CreateSafeAreaBridgeOptions, SafeAreaBridgeProjection, SafeAreaDomEffect, SafeAreaDomUpdate, } from './safe-area-bridge.ts';
|
|
46
|
+
export { createSafeAreaBridge } from './safe-area-bridge.ts';
|
|
47
|
+
export declare function compileDocumentShell({ document, shell, startupEffects, }: DocumentShellComposition): string;
|
|
48
|
+
export declare function validateDocumentShellTemplate(html: string, appEntry: string): void;
|
|
49
|
+
export type LocatedHtmlElement = {
|
|
50
|
+
attributes: Readonly<Record<string, string>>;
|
|
51
|
+
startOffset: number;
|
|
52
|
+
endOffset: number;
|
|
53
|
+
};
|
|
54
|
+
export declare function locateSingleStylesheetLink(html: string): LocatedHtmlElement;
|
|
55
|
+
export declare function validateCompiledDocumentShell(html: string, document: Pick<DocumentShellDocument, 'mountId' | 'appEntry'>, options?: {
|
|
56
|
+
transformedAppEntry?: boolean;
|
|
57
|
+
}): void;
|
|
58
|
+
export declare function validateRuntimeHandoffDocument(html: string): void;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
import { documentShellReadyAttribute, documentShellRuntimeStylesheetId, documentShellStaticAttribute, } from "./client.js";
|
|
3
|
+
export const documentShellEntryMarker = 'data-document-shell-entry';
|
|
4
|
+
export function htmlFragment(value) {
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
export function cssText(value) {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export function inlineScript(value) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export { createSafeAreaBridge } from "./safe-area-bridge.js";
|
|
14
|
+
function escapeAttribute(value) {
|
|
15
|
+
return value
|
|
16
|
+
.replaceAll('&', '&')
|
|
17
|
+
.replaceAll('"', '"')
|
|
18
|
+
.replaceAll('<', '<')
|
|
19
|
+
.replaceAll('>', '>');
|
|
20
|
+
}
|
|
21
|
+
function escapeText(value) {
|
|
22
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
23
|
+
}
|
|
24
|
+
function renderInlineEffect(effect) {
|
|
25
|
+
if (!/^data-[a-z0-9.-]+$/.test(effect.marker)) {
|
|
26
|
+
throw new Error('Document shell inline effect marker must be a data-* attribute name');
|
|
27
|
+
}
|
|
28
|
+
const script = effect.script.replace(/<\/script/gi, '<\\/script');
|
|
29
|
+
return ` <script ${escapeAttribute(effect.marker)}="true">${script}</script>`;
|
|
30
|
+
}
|
|
31
|
+
export function compileDocumentShell({ document, shell, startupEffects, }) {
|
|
32
|
+
if (!document.lang.trim())
|
|
33
|
+
throw new Error('Document shell requires a non-empty lang');
|
|
34
|
+
if (!document.title.trim())
|
|
35
|
+
throw new Error('Document shell requires a non-empty title');
|
|
36
|
+
if (!document.appEntry.startsWith('/')) {
|
|
37
|
+
throw new Error('Document shell appEntry must be an absolute browser path');
|
|
38
|
+
}
|
|
39
|
+
if (!document.mountId.trim())
|
|
40
|
+
throw new Error('Document shell requires a mountId');
|
|
41
|
+
if (!shell.html.trim())
|
|
42
|
+
throw new Error('Document shell renderer returned empty HTML');
|
|
43
|
+
if (shell.criticalCss.length === 0 || shell.criticalCss.some((css) => !css.trim())) {
|
|
44
|
+
throw new Error('Document shell renderer returned empty critical CSS');
|
|
45
|
+
}
|
|
46
|
+
const head = document.head.map((fragment) => fragment.trim()).join('\n');
|
|
47
|
+
const beforePaint = startupEffects?.beforePaint?.map(renderInlineEffect).join('\n') ?? '';
|
|
48
|
+
const criticalCss = shell.criticalCss.join('\n').replace(/<\/style/gi, '<\\/style');
|
|
49
|
+
const afterShell = startupEffects?.afterShell?.map((fragment) => fragment.trim()).join('\n') ?? '';
|
|
50
|
+
const html = `<!doctype html>
|
|
51
|
+
<html lang="${escapeAttribute(document.lang)}">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="UTF-8" />
|
|
54
|
+
<title>${escapeText(document.title)}</title>
|
|
55
|
+
${head}
|
|
56
|
+
${beforePaint}
|
|
57
|
+
<style data-document-shell="true">${criticalCss}</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
${shell.html}
|
|
61
|
+
${afterShell}
|
|
62
|
+
<div id="${escapeAttribute(document.mountId)}"></div>
|
|
63
|
+
<script type="module" src="${escapeAttribute(document.appEntry)}"></script>
|
|
64
|
+
</body>
|
|
65
|
+
</html>
|
|
66
|
+
`;
|
|
67
|
+
validateCompiledDocumentShell(html, document);
|
|
68
|
+
return html;
|
|
69
|
+
}
|
|
70
|
+
function isElement(node) {
|
|
71
|
+
return 'tagName' in node;
|
|
72
|
+
}
|
|
73
|
+
function findElements(node, predicate) {
|
|
74
|
+
const matches = [];
|
|
75
|
+
if (isElement(node) && predicate(node))
|
|
76
|
+
matches.push(node);
|
|
77
|
+
if ('childNodes' in node) {
|
|
78
|
+
for (const child of node.childNodes)
|
|
79
|
+
matches.push(...findElements(child, predicate));
|
|
80
|
+
}
|
|
81
|
+
if (isElement(node) && node.tagName === 'template' && 'content' in node) {
|
|
82
|
+
matches.push(...findElements(node.content, predicate));
|
|
83
|
+
}
|
|
84
|
+
return matches;
|
|
85
|
+
}
|
|
86
|
+
function attribute(element, name) {
|
|
87
|
+
return element.attrs.find((item) => item.name === name)?.value;
|
|
88
|
+
}
|
|
89
|
+
function assertSingleElement(elements, label) {
|
|
90
|
+
if (elements.length !== 1) {
|
|
91
|
+
throw new Error(`Document shell requires exactly one ${label}; found ${elements.length}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function validateDocumentShellTemplate(html, appEntry) {
|
|
95
|
+
const parsed = parse(html);
|
|
96
|
+
const markerElements = findElements(parsed, (element) => element.attrs.some((item) => item.name === documentShellEntryMarker));
|
|
97
|
+
assertSingleElement(markerElements, 'document shell template marker');
|
|
98
|
+
const marker = markerElements[0];
|
|
99
|
+
if (marker.tagName !== 'script' ||
|
|
100
|
+
attribute(marker, 'type') !== 'module' ||
|
|
101
|
+
attribute(marker, 'src') !== appEntry) {
|
|
102
|
+
throw new Error(`Document shell template marker must be the module entry ${appEntry}`);
|
|
103
|
+
}
|
|
104
|
+
const contributions = findElements(parsed, (element) => !['html', 'head', 'body'].includes(element.tagName) && element !== marker);
|
|
105
|
+
if (contributions.length > 0) {
|
|
106
|
+
throw new Error('Document shell template must contain only its module-entry sentinel; move document contributions into render()');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function locateSingleStylesheetLink(html) {
|
|
110
|
+
const parsed = parse(html, { sourceCodeLocationInfo: true });
|
|
111
|
+
const stylesheets = findElements(parsed, (element) => element.tagName === 'link' &&
|
|
112
|
+
(attribute(element, 'rel')?.toLowerCase().split(/\s+/).includes('stylesheet') ?? false));
|
|
113
|
+
if (stylesheets.length !== 1) {
|
|
114
|
+
throw new Error(`Document shell requires exactly one stylesheet link; found ${stylesheets.length}`);
|
|
115
|
+
}
|
|
116
|
+
const stylesheet = stylesheets[0];
|
|
117
|
+
const location = stylesheet.sourceCodeLocation?.startTag ?? stylesheet.sourceCodeLocation;
|
|
118
|
+
if (!location)
|
|
119
|
+
throw new Error('Document shell stylesheet link is missing a source location');
|
|
120
|
+
return {
|
|
121
|
+
attributes: Object.fromEntries(stylesheet.attrs.map((item) => [item.name, item.value])),
|
|
122
|
+
startOffset: location.startOffset,
|
|
123
|
+
endOffset: location.endOffset,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function validateCompiledDocumentShell(html, document, options = {}) {
|
|
127
|
+
const parsed = parse(html);
|
|
128
|
+
const doctypes = parsed.childNodes.filter((node) => node.nodeName === '#documentType');
|
|
129
|
+
if (doctypes.length !== 1) {
|
|
130
|
+
throw new Error(`Document shell requires exactly one doctype; found ${doctypes.length}`);
|
|
131
|
+
}
|
|
132
|
+
const markerElements = findElements(parsed, (element) => element.attrs.some((item) => item.name === documentShellEntryMarker));
|
|
133
|
+
if (markerElements.length > 0) {
|
|
134
|
+
throw new Error(`Document shell emitted its template marker: ${documentShellEntryMarker}`);
|
|
135
|
+
}
|
|
136
|
+
assertSingleElement(findElements(parsed, (element) => element.tagName === 'meta' && attribute(element, 'name')?.toLowerCase() === 'viewport'), 'viewport meta');
|
|
137
|
+
assertSingleElement(findElements(parsed, (element) => element.tagName === 'link' &&
|
|
138
|
+
(attribute(element, 'rel')?.toLowerCase().split(/\s+/).includes('manifest') ?? false)), 'manifest link');
|
|
139
|
+
assertSingleElement(findElements(parsed, (element) => element.tagName === 'title'), 'document title');
|
|
140
|
+
assertSingleElement(findElements(parsed, (element) => attribute(element, 'id') === document.mountId), `#${document.mountId} mount point`);
|
|
141
|
+
assertSingleElement(findElements(parsed, (element) => element.tagName === 'style' && attribute(element, 'data-document-shell') === 'true'), 'critical shell style');
|
|
142
|
+
assertSingleElement(findElements(parsed, (element) => element.attrs.some((item) => item.name === documentShellStaticAttribute)), 'static document shell marker');
|
|
143
|
+
assertSingleElement(findElements(parsed, (element) => element.tagName === 'script' &&
|
|
144
|
+
attribute(element, 'type') === 'module' &&
|
|
145
|
+
(options.transformedAppEntry
|
|
146
|
+
? Boolean(attribute(element, 'src'))
|
|
147
|
+
: attribute(element, 'src') === document.appEntry)), options.transformedAppEntry ? 'transformed module entry' : `module entry ${document.appEntry}`);
|
|
148
|
+
const safeAreaBridge = findElements(parsed, (element) => element.tagName === 'script' &&
|
|
149
|
+
attribute(element, 'data-document-shell-safe-area-bridge') === 'true');
|
|
150
|
+
if (safeAreaBridge.length > 0) {
|
|
151
|
+
const viewport = findElements(parsed, (element) => element.tagName === 'meta' && attribute(element, 'name')?.toLowerCase() === 'viewport')[0];
|
|
152
|
+
const viewportContent = attribute(viewport, 'content')
|
|
153
|
+
?.toLowerCase()
|
|
154
|
+
.split(',')
|
|
155
|
+
.map((part) => part.trim()) ?? [];
|
|
156
|
+
const requiredViewportParts = [
|
|
157
|
+
'width=device-width',
|
|
158
|
+
'initial-scale=1',
|
|
159
|
+
'viewport-fit=cover',
|
|
160
|
+
];
|
|
161
|
+
const missingViewportParts = requiredViewportParts.filter((part) => !viewportContent.includes(part));
|
|
162
|
+
if (missingViewportParts.length > 0) {
|
|
163
|
+
throw new Error(`Document shell safe-area bridge requires viewport content: ${missingViewportParts.join(', ')}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function validateRuntimeHandoffDocument(html) {
|
|
168
|
+
const parsed = parse(html);
|
|
169
|
+
assertSingleElement(findElements(parsed, (element) => element.tagName === 'link' &&
|
|
170
|
+
attribute(element, 'id') === documentShellRuntimeStylesheetId &&
|
|
171
|
+
attribute(element, 'rel') === 'preload' &&
|
|
172
|
+
attribute(element, 'as') === 'style' &&
|
|
173
|
+
attribute(element, 'onload') === undefined &&
|
|
174
|
+
attribute(element, 'onerror') === undefined), 'deferred runtime stylesheet');
|
|
175
|
+
assertSingleElement(findElements(parsed, (element) => element.tagName === 'script' &&
|
|
176
|
+
attribute(element, 'data-document-shell-runtime-stylesheet') === 'true'), 'runtime stylesheet bootstrap');
|
|
177
|
+
const initiallyReady = findElements(parsed, (element) => element.tagName === 'html' && attribute(element, documentShellReadyAttribute) !== undefined);
|
|
178
|
+
if (initiallyReady.length > 0) {
|
|
179
|
+
throw new Error(`Document shell cannot emit ${documentShellReadyAttribute} before runtime commit`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createReferenceSafeAreaBridge } from "./safe-area-bridge.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { HtmlFragment, InlineScript } from './document-shell.ts';
|
|
2
|
+
export type SafeAreaDomUpdate = {
|
|
3
|
+
kind: 'reserve';
|
|
4
|
+
profile: string;
|
|
5
|
+
orientation: 'portrait' | 'landscape';
|
|
6
|
+
bottom: number;
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'release';
|
|
9
|
+
profile: string;
|
|
10
|
+
orientation: 'portrait' | 'landscape';
|
|
11
|
+
bottom: number;
|
|
12
|
+
reason: 'native-stable' | 'orientation-changed';
|
|
13
|
+
};
|
|
14
|
+
export type SafeAreaDomEffect = {
|
|
15
|
+
reserveBottomCssVariable: `--${string}`;
|
|
16
|
+
profileAttribute?: `data-${string}`;
|
|
17
|
+
orientationAttribute?: `data-${string}`;
|
|
18
|
+
reserveAttribute?: `data-${string}`;
|
|
19
|
+
windowStateProperty?: string;
|
|
20
|
+
resultStateProperty?: string;
|
|
21
|
+
};
|
|
22
|
+
export type SafeAreaBridgeProjection = {
|
|
23
|
+
beforePaint: InlineScript;
|
|
24
|
+
probeHtml: HtmlFragment;
|
|
25
|
+
};
|
|
26
|
+
export type CreateSafeAreaBridgeOptions = {
|
|
27
|
+
domEffect: SafeAreaDomEffect;
|
|
28
|
+
};
|
|
29
|
+
export type CreateReferenceSafeAreaBridgeOptions = CreateSafeAreaBridgeOptions & {
|
|
30
|
+
diagnosticOverride?: {
|
|
31
|
+
enabledAttribute: `data-${string}`;
|
|
32
|
+
enabledValue?: string;
|
|
33
|
+
queryParameter: string;
|
|
34
|
+
bottom: number;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export declare function createSafeAreaBridge(options: CreateSafeAreaBridgeOptions): SafeAreaBridgeProjection;
|
|
38
|
+
export declare function createReferenceSafeAreaBridge(options: CreateReferenceSafeAreaBridgeOptions): SafeAreaBridgeProjection;
|