@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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { safeAreaCompatibilityProfiles, } from "./safe-area-profiles.js";
|
|
2
|
+
function assertProfiles(profiles) {
|
|
3
|
+
const ids = new Set();
|
|
4
|
+
const signatures = new Set();
|
|
5
|
+
for (const profile of profiles) {
|
|
6
|
+
if (ids.has(profile.id))
|
|
7
|
+
throw new Error(`Duplicate safe-area profile id: ${profile.id}`);
|
|
8
|
+
ids.add(profile.id);
|
|
9
|
+
if (!Number.isInteger(profile.minimumRuntimeVersion[0]) ||
|
|
10
|
+
profile.minimumRuntimeVersion[0] < 0 ||
|
|
11
|
+
!Number.isInteger(profile.minimumRuntimeVersion[1]) ||
|
|
12
|
+
profile.minimumRuntimeVersion[1] < 0) {
|
|
13
|
+
throw new Error(`Safe-area profile ${profile.id} has an invalid minimum runtime version`);
|
|
14
|
+
}
|
|
15
|
+
if (profile.reserve.bottom <= 0) {
|
|
16
|
+
throw new Error(`Safe-area profile ${profile.id} must provide a positive bottom reserve`);
|
|
17
|
+
}
|
|
18
|
+
const signature = JSON.stringify({
|
|
19
|
+
platform: profile.platform,
|
|
20
|
+
displayMode: profile.displayMode,
|
|
21
|
+
orientation: profile.orientation,
|
|
22
|
+
screen: profile.screen,
|
|
23
|
+
devicePixelRatio: profile.devicePixelRatio,
|
|
24
|
+
});
|
|
25
|
+
if (signatures.has(signature)) {
|
|
26
|
+
throw new Error(`Safe-area profile ${profile.id} overlaps another runtime matcher`);
|
|
27
|
+
}
|
|
28
|
+
signatures.add(signature);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function assertDomEffect(domEffect) {
|
|
32
|
+
if (!domEffect.reserveBottomCssVariable.startsWith('--')) {
|
|
33
|
+
throw new Error('Safe-area DOM effect CSS variable must start with --');
|
|
34
|
+
}
|
|
35
|
+
for (const [label, name] of Object.entries({
|
|
36
|
+
profile: domEffect.profileAttribute,
|
|
37
|
+
orientation: domEffect.orientationAttribute,
|
|
38
|
+
reserve: domEffect.reserveAttribute,
|
|
39
|
+
})) {
|
|
40
|
+
if (name !== undefined && !name.startsWith('data-')) {
|
|
41
|
+
throw new Error(`Safe-area DOM effect ${label} attribute must start with data-`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const [label, property] of Object.entries({
|
|
45
|
+
reserve: domEffect.windowStateProperty,
|
|
46
|
+
result: domEffect.resultStateProperty,
|
|
47
|
+
})) {
|
|
48
|
+
if (property !== undefined && !/^__[A-Za-z][A-Za-z0-9_]*$/.test(property)) {
|
|
49
|
+
throw new Error(`Safe-area DOM effect ${label} state property must use a private __name`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function createSafeAreaBridgeForRollouts({ domEffect, diagnosticOverride, }, rollouts) {
|
|
54
|
+
const stableFrames = 2;
|
|
55
|
+
const timeoutMs = 3_000;
|
|
56
|
+
assertProfiles(safeAreaCompatibilityProfiles);
|
|
57
|
+
assertDomEffect(domEffect);
|
|
58
|
+
const runtimeProfiles = safeAreaCompatibilityProfiles
|
|
59
|
+
.filter((profile) => rollouts.has(profile.rollout))
|
|
60
|
+
.map((profile) => ({
|
|
61
|
+
id: profile.id,
|
|
62
|
+
platform: profile.platform,
|
|
63
|
+
minimumRuntimeVersion: profile.minimumRuntimeVersion,
|
|
64
|
+
displayMode: profile.displayMode,
|
|
65
|
+
orientation: profile.orientation,
|
|
66
|
+
screen: profile.screen,
|
|
67
|
+
devicePixelRatio: profile.devicePixelRatio,
|
|
68
|
+
reserve: profile.reserve,
|
|
69
|
+
}));
|
|
70
|
+
const diagnosticSelection = diagnosticOverride
|
|
71
|
+
? `const diagnosticOverride = ${JSON.stringify({
|
|
72
|
+
enabledAttribute: diagnosticOverride.enabledAttribute,
|
|
73
|
+
enabledValue: diagnosticOverride.enabledValue ?? 'true',
|
|
74
|
+
queryParameter: diagnosticOverride.queryParameter,
|
|
75
|
+
bottom: diagnosticOverride.bottom,
|
|
76
|
+
})}
|
|
77
|
+
const diagnosticEnabled = document.documentElement.getAttribute(diagnosticOverride.enabledAttribute) === diagnosticOverride.enabledValue
|
|
78
|
+
const diagnosticBottom = Number(new URL(location.href).searchParams.get(diagnosticOverride.queryParameter))
|
|
79
|
+
const selectedProfile = diagnosticEnabled && diagnosticBottom === diagnosticOverride.bottom
|
|
80
|
+
? { id: 'diagnostic-override', orientation, reserve: { bottom: diagnosticBottom } }
|
|
81
|
+
: matchedProfile`
|
|
82
|
+
: 'const selectedProfile = matchedProfile';
|
|
83
|
+
const bootstrap = `(() => {
|
|
84
|
+
const profiles = ${JSON.stringify(runtimeProfiles)}
|
|
85
|
+
const domEffect = ${JSON.stringify(domEffect)}
|
|
86
|
+
const applyDomUpdate = (update) => {
|
|
87
|
+
const root = document.documentElement
|
|
88
|
+
if (update.kind === 'reserve') {
|
|
89
|
+
if (domEffect.profileAttribute) root.setAttribute(domEffect.profileAttribute, update.profile)
|
|
90
|
+
if (domEffect.orientationAttribute) root.setAttribute(domEffect.orientationAttribute, update.orientation)
|
|
91
|
+
if (domEffect.reserveAttribute) root.setAttribute(domEffect.reserveAttribute, String(update.bottom))
|
|
92
|
+
root.style.setProperty(domEffect.reserveBottomCssVariable, update.bottom + 'px')
|
|
93
|
+
if (domEffect.windowStateProperty) {
|
|
94
|
+
window[domEffect.windowStateProperty] = {
|
|
95
|
+
profile: update.profile,
|
|
96
|
+
orientation: update.orientation,
|
|
97
|
+
bottom: update.bottom,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (domEffect.reserveAttribute) root.removeAttribute(domEffect.reserveAttribute)
|
|
103
|
+
root.style.removeProperty(domEffect.reserveBottomCssVariable)
|
|
104
|
+
if (domEffect.windowStateProperty) window[domEffect.windowStateProperty] = undefined
|
|
105
|
+
if (update.reason === 'orientation-changed') {
|
|
106
|
+
if (domEffect.profileAttribute) root.removeAttribute(domEffect.profileAttribute)
|
|
107
|
+
if (domEffect.orientationAttribute) root.removeAttribute(domEffect.orientationAttribute)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const platform = /iPhone|iPad|iPod/.test(navigator.userAgent)
|
|
111
|
+
? 'ios'
|
|
112
|
+
: /Android/.test(navigator.userAgent)
|
|
113
|
+
? 'android'
|
|
114
|
+
: 'unknown'
|
|
115
|
+
const versionMatch = platform === 'ios'
|
|
116
|
+
? navigator.userAgent.match(/Version[/](\\d+)[.](\\d+)/)
|
|
117
|
+
: undefined
|
|
118
|
+
const runtimeVersion = versionMatch ? [Number(versionMatch[1]), Number(versionMatch[2])] : undefined
|
|
119
|
+
const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone === true
|
|
120
|
+
const orientation = matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape'
|
|
121
|
+
const versionAtLeast = (version, minimumVersion) => {
|
|
122
|
+
if (!version) return false
|
|
123
|
+
const value = version[0] * 1000 + version[1]
|
|
124
|
+
const minimum = minimumVersion[0] * 1000 + minimumVersion[1]
|
|
125
|
+
return value >= minimum
|
|
126
|
+
}
|
|
127
|
+
const matchedProfile = profiles.find((profile) =>
|
|
128
|
+
profile.platform === platform &&
|
|
129
|
+
versionAtLeast(runtimeVersion, profile.minimumRuntimeVersion) &&
|
|
130
|
+
profile.displayMode === (standalone ? 'standalone' : 'browser') &&
|
|
131
|
+
profile.orientation === orientation &&
|
|
132
|
+
profile.screen.width === screen.width &&
|
|
133
|
+
profile.screen.height === screen.height &&
|
|
134
|
+
profile.devicePixelRatio === devicePixelRatio
|
|
135
|
+
)
|
|
136
|
+
${diagnosticSelection}
|
|
137
|
+
if (!selectedProfile) {
|
|
138
|
+
if (domEffect.resultStateProperty) {
|
|
139
|
+
window[domEffect.resultStateProperty] = { status: 'inactive' }
|
|
140
|
+
}
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const bottom = selectedProfile.reserve.bottom
|
|
145
|
+
const reserveUpdate = {
|
|
146
|
+
kind: 'reserve',
|
|
147
|
+
profile: selectedProfile.id,
|
|
148
|
+
orientation: selectedProfile.orientation,
|
|
149
|
+
bottom,
|
|
150
|
+
}
|
|
151
|
+
applyDomUpdate(reserveUpdate)
|
|
152
|
+
|
|
153
|
+
let previousViewport
|
|
154
|
+
let stableViewportFrames = 0
|
|
155
|
+
let finished = false
|
|
156
|
+
let nativeBottom = 0
|
|
157
|
+
let timeoutHandle
|
|
158
|
+
const reportResult = (status, reason, nativeBottom) => {
|
|
159
|
+
if (!domEffect.resultStateProperty) return
|
|
160
|
+
window[domEffect.resultStateProperty] = {
|
|
161
|
+
profile: selectedProfile.id,
|
|
162
|
+
status,
|
|
163
|
+
reason,
|
|
164
|
+
nativeBottom,
|
|
165
|
+
bottom,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const stopOrientationWatch = () => {
|
|
169
|
+
window.removeEventListener('orientationchange', releaseForOrientationChange)
|
|
170
|
+
window.removeEventListener('resize', releaseForOrientationChange)
|
|
171
|
+
}
|
|
172
|
+
const releaseForOrientationChange = () => {
|
|
173
|
+
if (matchMedia('(orientation: portrait)').matches === (orientation === 'portrait')) return
|
|
174
|
+
applyDomUpdate({ ...reserveUpdate, kind: 'release', reason: 'orientation-changed' })
|
|
175
|
+
clearTimeout(timeoutHandle)
|
|
176
|
+
finished = true
|
|
177
|
+
stopOrientationWatch()
|
|
178
|
+
reportResult('released', 'orientation-changed', nativeBottom)
|
|
179
|
+
}
|
|
180
|
+
const finish = (status, reason, nativeBottom, keepOrientationWatch = false) => {
|
|
181
|
+
if (finished) return
|
|
182
|
+
finished = true
|
|
183
|
+
clearTimeout(timeoutHandle)
|
|
184
|
+
if (!keepOrientationWatch) stopOrientationWatch()
|
|
185
|
+
reportResult(status, reason, nativeBottom)
|
|
186
|
+
}
|
|
187
|
+
window.addEventListener('orientationchange', releaseForOrientationChange)
|
|
188
|
+
window.addEventListener('resize', releaseForOrientationChange)
|
|
189
|
+
timeoutHandle = setTimeout(() => {
|
|
190
|
+
finish('unresolved', 'timeout', nativeBottom, true)
|
|
191
|
+
}, ${timeoutMs})
|
|
192
|
+
const sample = () => {
|
|
193
|
+
if (finished) return
|
|
194
|
+
if (matchMedia('(orientation: portrait)').matches !== (orientation === 'portrait')) {
|
|
195
|
+
releaseForOrientationChange()
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (document.visibilityState !== 'visible') {
|
|
199
|
+
requestAnimationFrame(sample)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
const probe = document.querySelector('[data-document-shell-native-safe-area]')
|
|
203
|
+
nativeBottom = probe ? Number.parseFloat(getComputedStyle(probe).paddingBottom) || 0 : 0
|
|
204
|
+
const viewport = [nativeBottom, innerWidth, innerHeight, window.visualViewport?.width ?? 0, window.visualViewport?.height ?? 0, window.visualViewport?.offsetTop ?? 0].join(':')
|
|
205
|
+
stableViewportFrames = viewport === previousViewport ? stableViewportFrames + 1 : 1
|
|
206
|
+
previousViewport = viewport
|
|
207
|
+
if (nativeBottom >= bottom && stableViewportFrames >= ${stableFrames}) {
|
|
208
|
+
applyDomUpdate({ ...reserveUpdate, kind: 'release', reason: 'native-stable' })
|
|
209
|
+
finish('released', 'native-stable', nativeBottom)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
requestAnimationFrame(sample)
|
|
213
|
+
}
|
|
214
|
+
requestAnimationFrame(sample)
|
|
215
|
+
})()`;
|
|
216
|
+
return {
|
|
217
|
+
beforePaint: bootstrap,
|
|
218
|
+
probeHtml: ' <div data-document-shell-native-safe-area aria-hidden="true" style="position:fixed;visibility:hidden;pointer-events:none;padding-bottom:env(safe-area-inset-bottom)"></div>',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export function createSafeAreaBridge(options) {
|
|
222
|
+
return createSafeAreaBridgeForRollouts(options, new Set(['sharedDefault']));
|
|
223
|
+
}
|
|
224
|
+
export function createReferenceSafeAreaBridge(options) {
|
|
225
|
+
return createSafeAreaBridgeForRollouts(options, new Set(['sharedDefault', 'referenceProduction']));
|
|
226
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type SafeAreaCompatibilityProfile = {
|
|
2
|
+
id: string;
|
|
3
|
+
platform: 'ios' | 'android';
|
|
4
|
+
minimumRuntimeVersion: readonly [major: number, minor: number];
|
|
5
|
+
displayMode: 'standalone' | 'browser';
|
|
6
|
+
orientation: 'portrait' | 'landscape';
|
|
7
|
+
screen: {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
devicePixelRatio: number;
|
|
12
|
+
reserve: {
|
|
13
|
+
bottom: number;
|
|
14
|
+
};
|
|
15
|
+
maturity: 'candidate' | 'provisional' | 'verified' | 'established';
|
|
16
|
+
rollout: 'probe' | 'referenceProduction' | 'consumerOptIn' | 'sharedDefault';
|
|
17
|
+
};
|
|
18
|
+
export declare const safeAreaCompatibilityProfiles: ({
|
|
19
|
+
id: string;
|
|
20
|
+
platform: "ios";
|
|
21
|
+
minimumRuntimeVersion: [number, number];
|
|
22
|
+
displayMode: "standalone";
|
|
23
|
+
orientation: "portrait";
|
|
24
|
+
screen: {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
devicePixelRatio: number;
|
|
29
|
+
reserve: {
|
|
30
|
+
bottom: number;
|
|
31
|
+
};
|
|
32
|
+
maturity: "provisional";
|
|
33
|
+
rollout: "referenceProduction";
|
|
34
|
+
} | {
|
|
35
|
+
id: string;
|
|
36
|
+
platform: "ios";
|
|
37
|
+
minimumRuntimeVersion: [number, number];
|
|
38
|
+
displayMode: "standalone";
|
|
39
|
+
orientation: "portrait";
|
|
40
|
+
screen: {
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
};
|
|
44
|
+
devicePixelRatio: number;
|
|
45
|
+
reserve: {
|
|
46
|
+
bottom: number;
|
|
47
|
+
};
|
|
48
|
+
maturity: "verified";
|
|
49
|
+
rollout: "referenceProduction";
|
|
50
|
+
})[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// This private catalog is package-owned. The public root projects only
|
|
2
|
+
// sharedDefault entries; the explicit reference subpath additionally projects
|
|
3
|
+
// referenceProduction entries for the source application that supplied their
|
|
4
|
+
// current evidence. Consumers do not select or redefine individual profiles.
|
|
5
|
+
export const safeAreaCompatibilityProfiles = [
|
|
6
|
+
// Provisional entries come from the iOS 26.5 Simulator S2-S5 matrix recorded
|
|
7
|
+
// in Base's Document Shell platform-extraction plan. The verified reference
|
|
8
|
+
// entry additionally has repeated real-device video + trace evidence.
|
|
9
|
+
{
|
|
10
|
+
id: 'ios-375x812-3x-portrait-standalone',
|
|
11
|
+
platform: 'ios',
|
|
12
|
+
minimumRuntimeVersion: [26, 0],
|
|
13
|
+
displayMode: 'standalone',
|
|
14
|
+
orientation: 'portrait',
|
|
15
|
+
screen: { width: 375, height: 812 },
|
|
16
|
+
devicePixelRatio: 3,
|
|
17
|
+
reserve: { bottom: 34 },
|
|
18
|
+
maturity: 'provisional',
|
|
19
|
+
rollout: 'referenceProduction',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'ios-393x852-3x-portrait-standalone',
|
|
23
|
+
platform: 'ios',
|
|
24
|
+
minimumRuntimeVersion: [26, 0],
|
|
25
|
+
displayMode: 'standalone',
|
|
26
|
+
orientation: 'portrait',
|
|
27
|
+
screen: { width: 393, height: 852 },
|
|
28
|
+
devicePixelRatio: 3,
|
|
29
|
+
reserve: { bottom: 34 },
|
|
30
|
+
maturity: 'provisional',
|
|
31
|
+
rollout: 'referenceProduction',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'ios-402x874-3x-portrait-standalone',
|
|
35
|
+
platform: 'ios',
|
|
36
|
+
minimumRuntimeVersion: [26, 0],
|
|
37
|
+
displayMode: 'standalone',
|
|
38
|
+
orientation: 'portrait',
|
|
39
|
+
screen: { width: 402, height: 874 },
|
|
40
|
+
devicePixelRatio: 3,
|
|
41
|
+
reserve: { bottom: 34 },
|
|
42
|
+
maturity: 'verified',
|
|
43
|
+
rollout: 'referenceProduction',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'ios-430x932-3x-portrait-standalone',
|
|
47
|
+
platform: 'ios',
|
|
48
|
+
minimumRuntimeVersion: [26, 0],
|
|
49
|
+
displayMode: 'standalone',
|
|
50
|
+
orientation: 'portrait',
|
|
51
|
+
screen: { width: 430, height: 932 },
|
|
52
|
+
devicePixelRatio: 3,
|
|
53
|
+
reserve: { bottom: 34 },
|
|
54
|
+
maturity: 'provisional',
|
|
55
|
+
rollout: 'referenceProduction',
|
|
56
|
+
},
|
|
57
|
+
];
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { type DocumentShellBuildContext, type DocumentShellComposition } from './document-shell.ts';
|
|
3
|
+
export type DocumentShellPluginOptions = {
|
|
4
|
+
render: (context: DocumentShellBuildContext) => DocumentShellComposition | Promise<DocumentShellComposition>;
|
|
5
|
+
validateFinalDocument?: (html: string, context: DocumentShellBuildContext) => void;
|
|
6
|
+
runtimeHandoff?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function documentShell(options: DocumentShellPluginOptions): Plugin[];
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { compileDocumentShell, locateSingleStylesheetLink, validateCompiledDocumentShell, validateDocumentShellTemplate, validateRuntimeHandoffDocument, } from "./document-shell.js";
|
|
3
|
+
import { documentShellRuntimeStylesheetId, } from "./client.js";
|
|
4
|
+
function readIndexAsset(bundle) {
|
|
5
|
+
const indexAsset = Object.values(bundle).find((entry) => typeof entry === 'object' &&
|
|
6
|
+
entry !== null &&
|
|
7
|
+
'type' in entry &&
|
|
8
|
+
entry.type === 'asset' &&
|
|
9
|
+
'fileName' in entry &&
|
|
10
|
+
entry.fileName === 'index.html');
|
|
11
|
+
if (!indexAsset)
|
|
12
|
+
throw new Error('Document shell could not find emitted index.html');
|
|
13
|
+
return indexAsset;
|
|
14
|
+
}
|
|
15
|
+
function escapeHtmlAttribute(value) {
|
|
16
|
+
return value
|
|
17
|
+
.replaceAll('&', '&')
|
|
18
|
+
.replaceAll('"', '"')
|
|
19
|
+
.replaceAll('<', '<')
|
|
20
|
+
.replaceAll('>', '>');
|
|
21
|
+
}
|
|
22
|
+
function deferRuntimeStylesheet() {
|
|
23
|
+
return {
|
|
24
|
+
name: 'document-shell:defer-runtime-stylesheet',
|
|
25
|
+
apply: 'build',
|
|
26
|
+
enforce: 'post',
|
|
27
|
+
transformIndexHtml(html) {
|
|
28
|
+
const stylesheet = locateSingleStylesheetLink(html);
|
|
29
|
+
const href = stylesheet.attributes.href;
|
|
30
|
+
if (!href)
|
|
31
|
+
throw new Error('Emitted runtime stylesheet is missing an href');
|
|
32
|
+
const replacedAttributes = new Set(['id', 'rel', 'as', 'href', 'onload', 'onerror']);
|
|
33
|
+
const forwardedAttributes = Object.entries(stylesheet.attributes)
|
|
34
|
+
.filter(([name]) => !replacedAttributes.has(name))
|
|
35
|
+
.map(([name, value]) => value === '' ? ` ${name}` : ` ${name}="${escapeHtmlAttribute(value)}"`)
|
|
36
|
+
.join('');
|
|
37
|
+
const bootstrap = `<script data-document-shell-runtime-stylesheet="true">(()=>{const stylesheet=document.getElementById('${documentShellRuntimeStylesheetId}');if(!stylesheet)return;const emit=(state,detail)=>document.dispatchEvent(new CustomEvent('document-shell:runtime-stylesheet',{detail:{state,detail}}));const markFailure=(failure)=>{if(stylesheet.dataset.failure===failure)return;stylesheet.dataset.failure=failure;emit('failed',failure)};const deadline=Date.now()+3000;stylesheet.dataset.failureDeadline=String(deadline);const timer=window.setTimeout(()=>{if(stylesheet.dataset.loaded!=='true')markFailure('timeout')},Math.max(0,deadline-Date.now()));stylesheet.addEventListener('load',()=>{window.clearTimeout(timer);stylesheet.dataset.loaded='true';stylesheet.rel='stylesheet';emit('loaded')},{once:true});stylesheet.addEventListener('error',()=>{window.clearTimeout(timer);markFailure('error')},{once:true})})()</script>`;
|
|
38
|
+
const deferredLink = `<link id="${documentShellRuntimeStylesheetId}" rel="preload" as="style"${forwardedAttributes} href="${escapeHtmlAttribute(href)}">${bootstrap}`;
|
|
39
|
+
return `${html.slice(0, stylesheet.startOffset)}${deferredLink}${html.slice(stylesheet.endOffset)}`;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function documentShell(options) {
|
|
44
|
+
let resolvedDocument;
|
|
45
|
+
let command = 'build';
|
|
46
|
+
let mode = 'production';
|
|
47
|
+
const producer = {
|
|
48
|
+
name: 'document-shell:compile',
|
|
49
|
+
enforce: 'pre',
|
|
50
|
+
configResolved(config) {
|
|
51
|
+
command = config.command;
|
|
52
|
+
mode = config.mode;
|
|
53
|
+
},
|
|
54
|
+
transformIndexHtml: {
|
|
55
|
+
order: 'pre',
|
|
56
|
+
async handler(template) {
|
|
57
|
+
const buildContext = { command, mode };
|
|
58
|
+
const composition = await options.render(buildContext);
|
|
59
|
+
validateDocumentShellTemplate(template, composition.document.appEntry);
|
|
60
|
+
resolvedDocument = composition.document;
|
|
61
|
+
return compileDocumentShell(composition);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const finalGate = {
|
|
66
|
+
name: 'document-shell:final-artifact-gate',
|
|
67
|
+
apply: 'build',
|
|
68
|
+
enforce: 'post',
|
|
69
|
+
generateBundle(_outputOptions, bundle) {
|
|
70
|
+
if (!resolvedDocument) {
|
|
71
|
+
throw new Error('Document shell final gate ran before the document compiler');
|
|
72
|
+
}
|
|
73
|
+
const indexAsset = readIndexAsset(bundle);
|
|
74
|
+
const html = typeof indexAsset.source === 'string'
|
|
75
|
+
? indexAsset.source
|
|
76
|
+
: Buffer.from(indexAsset.source).toString('utf8');
|
|
77
|
+
validateCompiledDocumentShell(html, resolvedDocument, { transformedAppEntry: true });
|
|
78
|
+
if (options.runtimeHandoff)
|
|
79
|
+
validateRuntimeHandoffDocument(html);
|
|
80
|
+
options.validateFinalDocument?.(html, { command, mode });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return options.runtimeHandoff
|
|
84
|
+
? [producer, deferRuntimeStylesheet(), finalGate]
|
|
85
|
+
: [producer, finalGate];
|
|
86
|
+
}
|