@webstir-io/webstir-frontend 0.1.40
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 +158 -0
- package/dist/assets/assetManifest.d.ts +16 -0
- package/dist/assets/assetManifest.js +31 -0
- package/dist/assets/imageOptimizer.d.ts +6 -0
- package/dist/assets/imageOptimizer.js +93 -0
- package/dist/assets/precompression.d.ts +1 -0
- package/dist/assets/precompression.js +21 -0
- package/dist/builders/contentBuilder.d.ts +2 -0
- package/dist/builders/contentBuilder.js +1052 -0
- package/dist/builders/cssBuilder.d.ts +2 -0
- package/dist/builders/cssBuilder.js +439 -0
- package/dist/builders/htmlBuilder.d.ts +2 -0
- package/dist/builders/htmlBuilder.js +430 -0
- package/dist/builders/index.d.ts +2 -0
- package/dist/builders/index.js +14 -0
- package/dist/builders/jsBuilder.d.ts +2 -0
- package/dist/builders/jsBuilder.js +300 -0
- package/dist/builders/staticAssetsBuilder.d.ts +2 -0
- package/dist/builders/staticAssetsBuilder.js +158 -0
- package/dist/builders/types.d.ts +12 -0
- package/dist/builders/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +105 -0
- package/dist/config/manifest.d.ts +7 -0
- package/dist/config/manifest.js +17 -0
- package/dist/config/paths.d.ts +3 -0
- package/dist/config/paths.js +11 -0
- package/dist/config/schema.d.ts +413 -0
- package/dist/config/schema.js +44 -0
- package/dist/config/setup.d.ts +2 -0
- package/dist/config/setup.js +12 -0
- package/dist/config/workspace.d.ts +2 -0
- package/dist/config/workspace.js +131 -0
- package/dist/config/workspaceManifest.d.ts +23 -0
- package/dist/config/workspaceManifest.js +1 -0
- package/dist/core/constants.d.ts +70 -0
- package/dist/core/constants.js +70 -0
- package/dist/core/diagnostics.d.ts +15 -0
- package/dist/core/diagnostics.js +21 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +3 -0
- package/dist/core/pages.d.ts +6 -0
- package/dist/core/pages.js +23 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +115 -0
- package/dist/html/criticalCss.d.ts +4 -0
- package/dist/html/criticalCss.js +192 -0
- package/dist/html/htmlSecurity.d.ts +5 -0
- package/dist/html/htmlSecurity.js +73 -0
- package/dist/html/lazyLoad.d.ts +6 -0
- package/dist/html/lazyLoad.js +21 -0
- package/dist/html/pageScaffold.d.ts +10 -0
- package/dist/html/pageScaffold.js +51 -0
- package/dist/html/resourceHints.d.ts +7 -0
- package/dist/html/resourceHints.js +64 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/modes/ssg/index.d.ts +4 -0
- package/dist/modes/ssg/index.js +4 -0
- package/dist/modes/ssg/metadata.d.ts +5 -0
- package/dist/modes/ssg/metadata.js +50 -0
- package/dist/modes/ssg/routing.d.ts +2 -0
- package/dist/modes/ssg/routing.js +186 -0
- package/dist/modes/ssg/seo.d.ts +4 -0
- package/dist/modes/ssg/seo.js +208 -0
- package/dist/modes/ssg/validation.d.ts +3 -0
- package/dist/modes/ssg/validation.js +27 -0
- package/dist/modes/ssg/views.d.ts +2 -0
- package/dist/modes/ssg/views.js +236 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +102 -0
- package/dist/pipeline.d.ts +7 -0
- package/dist/pipeline.js +71 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +176 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/utils/changedFile.d.ts +8 -0
- package/dist/utils/changedFile.js +26 -0
- package/dist/utils/fs.d.ts +11 -0
- package/dist/utils/fs.js +39 -0
- package/dist/utils/hash.d.ts +1 -0
- package/dist/utils/hash.js +5 -0
- package/dist/utils/pagePaths.d.ts +5 -0
- package/dist/utils/pagePaths.js +36 -0
- package/dist/utils/pathMatch.d.ts +3 -0
- package/dist/utils/pathMatch.js +29 -0
- package/dist/watch/frontendFiles.d.ts +3 -0
- package/dist/watch/frontendFiles.js +25 -0
- package/dist/watch/hotUpdateTracker.d.ts +51 -0
- package/dist/watch/hotUpdateTracker.js +205 -0
- package/dist/watch/pipelineHelpers.d.ts +26 -0
- package/dist/watch/pipelineHelpers.js +177 -0
- package/dist/watch/types.d.ts +27 -0
- package/dist/watch/types.js +1 -0
- package/dist/watch/watchCoordinator.d.ts +36 -0
- package/dist/watch/watchCoordinator.js +551 -0
- package/dist/watch/watchDaemon.d.ts +17 -0
- package/dist/watch/watchDaemon.js +127 -0
- package/dist/watch/watchReporter.d.ts +21 -0
- package/dist/watch/watchReporter.js +64 -0
- package/package.json +92 -0
- package/scripts/publish.sh +101 -0
- package/scripts/smoke.mjs +35 -0
- package/scripts/update-contract.sh +121 -0
- package/src/assets/assetManifest.ts +51 -0
- package/src/assets/imageOptimizer.ts +112 -0
- package/src/assets/precompression.ts +25 -0
- package/src/builders/contentBuilder.ts +1400 -0
- package/src/builders/cssBuilder.ts +552 -0
- package/src/builders/htmlBuilder.ts +540 -0
- package/src/builders/index.ts +16 -0
- package/src/builders/jsBuilder.ts +358 -0
- package/src/builders/staticAssetsBuilder.ts +174 -0
- package/src/builders/types.ts +15 -0
- package/src/cli.ts +108 -0
- package/src/config/manifest.ts +24 -0
- package/src/config/paths.ts +14 -0
- package/src/config/schema.ts +49 -0
- package/src/config/setup.ts +14 -0
- package/src/config/workspace.ts +150 -0
- package/src/config/workspaceManifest.ts +27 -0
- package/src/core/constants.ts +73 -0
- package/src/core/diagnostics.ts +40 -0
- package/src/core/index.ts +3 -0
- package/src/core/pages.ts +31 -0
- package/src/hooks.ts +175 -0
- package/src/html/criticalCss.ts +214 -0
- package/src/html/htmlSecurity.ts +86 -0
- package/src/html/lazyLoad.ts +30 -0
- package/src/html/pageScaffold.ts +70 -0
- package/src/html/resourceHints.ts +91 -0
- package/src/index.ts +5 -0
- package/src/modes/ssg/index.ts +4 -0
- package/src/modes/ssg/metadata.ts +63 -0
- package/src/modes/ssg/routing.ts +230 -0
- package/src/modes/ssg/seo.ts +261 -0
- package/src/modes/ssg/validation.ts +37 -0
- package/src/modes/ssg/views.ts +309 -0
- package/src/operations.ts +138 -0
- package/src/pipeline.ts +88 -0
- package/src/provider.ts +249 -0
- package/src/types.ts +67 -0
- package/src/utils/changedFile.ts +39 -0
- package/src/utils/fs.ts +48 -0
- package/src/utils/hash.ts +6 -0
- package/src/utils/pagePaths.ts +43 -0
- package/src/utils/pathMatch.ts +36 -0
- package/src/watch/frontendFiles.ts +32 -0
- package/src/watch/hotUpdateTracker.ts +285 -0
- package/src/watch/pipelineHelpers.ts +242 -0
- package/src/watch/types.ts +23 -0
- package/src/watch/watchCoordinator.ts +666 -0
- package/src/watch/watchDaemon.ts +144 -0
- package/src/watch/watchReporter.ts +98 -0
- package/tests/add-page-defaults.test.js +64 -0
- package/tests/content-pages.test.js +81 -0
- package/tests/css-app-imports.test.js +64 -0
- package/tests/css-page-imports.test.js +100 -0
- package/tests/diagnostics.test.js +48 -0
- package/tests/features.test.js +63 -0
- package/tests/hooks.test.js +71 -0
- package/tests/provider.integration.test.js +137 -0
- package/tests/ssg-defaults.test.js +201 -0
- package/tests/ssg-guardrails.test.js +69 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import * as cssoModule from 'csso';
|
|
3
|
+
import type { CheerioAPI } from 'cheerio';
|
|
4
|
+
import { EXTENSIONS } from '../core/constants.js';
|
|
5
|
+
import { pathExists, readFile, stat } from '../utils/fs.js';
|
|
6
|
+
import { resolvePageAssetUrl } from '../utils/pagePaths.js';
|
|
7
|
+
|
|
8
|
+
const INLINE_THRESHOLD_BYTES = 6 * 1024;
|
|
9
|
+
const csso = ((cssoModule as unknown as { default?: typeof cssoModule }).default ?? cssoModule) as typeof cssoModule;
|
|
10
|
+
|
|
11
|
+
function minifyCriticalCss(css: string): string {
|
|
12
|
+
return csso.minify(css).css;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const APP_SHELL_CRITICAL_CSS = minifyCriticalCss(`
|
|
16
|
+
@layer tokens {
|
|
17
|
+
:root {
|
|
18
|
+
--ws-header-control-size: 2.6rem;
|
|
19
|
+
--ws-header-block-padding: 0.75rem;
|
|
20
|
+
--ws-header-sticky-offset: calc(var(--ws-header-control-size) + (var(--ws-header-block-padding) * 2) + 1px);
|
|
21
|
+
--ws-font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
22
|
+
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@layer reset {
|
|
27
|
+
*,
|
|
28
|
+
*::before,
|
|
29
|
+
*::after {
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@layer base {
|
|
35
|
+
html,
|
|
36
|
+
body {
|
|
37
|
+
height: 100%;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
body {
|
|
41
|
+
margin: 0;
|
|
42
|
+
font-family: var(--ws-font-sans);
|
|
43
|
+
font-size: 16px;
|
|
44
|
+
line-height: 1.6;
|
|
45
|
+
padding-top: var(--ws-header-sticky-offset, 0px);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
h1,
|
|
49
|
+
h2,
|
|
50
|
+
h3,
|
|
51
|
+
h4,
|
|
52
|
+
h5,
|
|
53
|
+
h6 {
|
|
54
|
+
line-height: 1.25;
|
|
55
|
+
margin: 0 0 0.5rem 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
h1 {
|
|
59
|
+
font-size: clamp(2rem, 4vw, 2.75rem);
|
|
60
|
+
letter-spacing: -0.02em;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
h2 {
|
|
64
|
+
font-size: clamp(1.5rem, 2.5vw, 2rem);
|
|
65
|
+
letter-spacing: -0.01em;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
h3 {
|
|
69
|
+
font-size: 1.35rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
p {
|
|
73
|
+
margin: 0 0 1rem 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@layer components {
|
|
78
|
+
.app-header {
|
|
79
|
+
position: fixed;
|
|
80
|
+
top: 0;
|
|
81
|
+
left: 0;
|
|
82
|
+
right: 0;
|
|
83
|
+
height: calc(var(--ws-header-sticky-offset) - 1px);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
`.trim());
|
|
87
|
+
const DOCS_SHELL_CRITICAL_CSS = minifyCriticalCss(`
|
|
88
|
+
@layer overrides {
|
|
89
|
+
.docs-layout {
|
|
90
|
+
--ws-docs-sidebar-width: clamp(14rem, 20vw, 18rem);
|
|
91
|
+
--ws-docs-layout-padding: 48px 0 96px;
|
|
92
|
+
--ws-container: 100%;
|
|
93
|
+
padding: var(--ws-docs-layout-padding);
|
|
94
|
+
padding-top: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.docs-layout__inner {
|
|
98
|
+
display: grid;
|
|
99
|
+
grid-template-columns: var(--ws-docs-sidebar-width, 16rem) minmax(0, 1fr);
|
|
100
|
+
gap: var(--ws-space-6, 1.5rem);
|
|
101
|
+
align-items: start;
|
|
102
|
+
padding-inline: 0;
|
|
103
|
+
margin-inline: 0;
|
|
104
|
+
min-height: calc(100vh - var(--ws-header-sticky-offset, 0px));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.docs-main {
|
|
108
|
+
width: 100%;
|
|
109
|
+
max-width: var(--ws-article, 72ch);
|
|
110
|
+
margin-inline: 0;
|
|
111
|
+
min-width: 0;
|
|
112
|
+
grid-column: 2;
|
|
113
|
+
padding-top: var(--ws-space-5, 1.25rem);
|
|
114
|
+
padding-right: var(--ws-space-6, 1.5rem);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@media (max-width: 40rem) {
|
|
118
|
+
.docs-layout__inner {
|
|
119
|
+
grid-template-columns: minmax(0, 1fr);
|
|
120
|
+
padding-left: max(var(--ws-container-pad, 1rem), env(safe-area-inset-left));
|
|
121
|
+
padding-right: max(var(--ws-container-pad, 1rem), env(safe-area-inset-right));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.docs-main {
|
|
125
|
+
max-width: none;
|
|
126
|
+
justify-self: stretch;
|
|
127
|
+
grid-column: auto;
|
|
128
|
+
padding-top: var(--ws-space-4, 1rem);
|
|
129
|
+
padding-right: 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
`.trim());
|
|
134
|
+
|
|
135
|
+
export async function inlineCriticalCss(
|
|
136
|
+
document: CheerioAPI,
|
|
137
|
+
pageName: string,
|
|
138
|
+
pagesRoot: string,
|
|
139
|
+
pagesUrlPrefix: string,
|
|
140
|
+
cssFile?: string
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
if (!cssFile) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const cssPath = path.join(pagesRoot, pageName, cssFile);
|
|
147
|
+
if (!(await pathExists(cssPath))) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const info = await stat(cssPath).catch(() => null);
|
|
152
|
+
if (!info || !info.isFile() || info.size > INLINE_THRESHOLD_BYTES) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cssContent = minifyCriticalCss(await readFile(cssPath));
|
|
157
|
+
const head = document('head').first();
|
|
158
|
+
if (head.length === 0) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const href = resolvePageAssetUrl(pagesUrlPrefix, pageName, cssFile);
|
|
163
|
+
document(`link[href="${href}"]`).remove();
|
|
164
|
+
|
|
165
|
+
if (cssFile.endsWith(EXTENSIONS.css)) {
|
|
166
|
+
document(`link[rel="preload"][href="${href}"]`).remove();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
head.append(`\n<style data-critical>\n${cssContent}\n</style>\n`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function ensureAppShellCriticalCss(document: CheerioAPI, appCssHref: string): void {
|
|
173
|
+
const head = document('head').first();
|
|
174
|
+
if (head.length === 0) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const existing = head.find('style[data-critical="app"]').first();
|
|
179
|
+
if (existing.length > 0) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const stylesheet = document(`link[rel="stylesheet"][href="${appCssHref}"]`).first();
|
|
184
|
+
const styleTag = `<style data-critical="app">\n${APP_SHELL_CRITICAL_CSS}\n</style>`;
|
|
185
|
+
if (stylesheet.length > 0) {
|
|
186
|
+
stylesheet.before(styleTag);
|
|
187
|
+
} else {
|
|
188
|
+
head.append(styleTag);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function ensureDocsShellCriticalCss(document: CheerioAPI): void {
|
|
193
|
+
const head = document('head').first();
|
|
194
|
+
if (head.length === 0) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const existing = head.find('style[data-critical="docs"]').first();
|
|
199
|
+
if (existing.length > 0) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const docsStylesheet = document('link[rel="stylesheet"]').filter((_, element) => {
|
|
204
|
+
const href = document(element).attr('href');
|
|
205
|
+
return typeof href === 'string' && href.includes('/docs/');
|
|
206
|
+
}).first();
|
|
207
|
+
|
|
208
|
+
const styleTag = `<style data-critical="docs">\n${DOCS_SHELL_CRITICAL_CSS}\n</style>`;
|
|
209
|
+
if (docsStylesheet.length > 0) {
|
|
210
|
+
docsStylesheet.before(styleTag);
|
|
211
|
+
} else {
|
|
212
|
+
head.append(styleTag);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { CheerioAPI } from 'cheerio';
|
|
3
|
+
|
|
4
|
+
const HTTP_TIMEOUT_MS = 5000;
|
|
5
|
+
|
|
6
|
+
export interface SubresourceIntegrityResult {
|
|
7
|
+
readonly failures: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function addSubresourceIntegrity(document: CheerioAPI): Promise<SubresourceIntegrityResult> {
|
|
11
|
+
const failures: string[] = [];
|
|
12
|
+
await Promise.all([
|
|
13
|
+
processScripts(document, failures),
|
|
14
|
+
processStylesheets(document, failures)
|
|
15
|
+
]);
|
|
16
|
+
return { failures };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function processScripts(document: CheerioAPI, failures: string[]): Promise<void> {
|
|
20
|
+
const scripts = document('script[src]').toArray();
|
|
21
|
+
await Promise.all(scripts.map(async (element) => {
|
|
22
|
+
const script = document(element);
|
|
23
|
+
const src = script.attr('src');
|
|
24
|
+
if (!src || !isExternal(src) || script.attr('integrity')) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sri = await fetchIntegrity(src);
|
|
29
|
+
if (!sri) {
|
|
30
|
+
failures.push(src);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
script.attr('integrity', sri);
|
|
35
|
+
if (!script.attr('crossorigin')) {
|
|
36
|
+
script.attr('crossorigin', 'anonymous');
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function processStylesheets(document: CheerioAPI, failures: string[]): Promise<void> {
|
|
42
|
+
const links = document('link[rel="stylesheet"][href]').toArray();
|
|
43
|
+
await Promise.all(links.map(async (element) => {
|
|
44
|
+
const link = document(element);
|
|
45
|
+
const href = link.attr('href');
|
|
46
|
+
if (!href || !isExternal(href) || link.attr('integrity')) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sri = await fetchIntegrity(href);
|
|
51
|
+
if (!sri) {
|
|
52
|
+
failures.push(href);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
link.attr('integrity', sri);
|
|
57
|
+
if (!link.attr('crossorigin')) {
|
|
58
|
+
link.attr('crossorigin', 'anonymous');
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isExternal(url: string): boolean {
|
|
64
|
+
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchIntegrity(url: string): Promise<string | null> {
|
|
68
|
+
try {
|
|
69
|
+
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timeout = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(normalizedUrl, { signal: controller.signal });
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
78
|
+
const hash = createHash('sha384').update(Buffer.from(arrayBuffer)).digest('base64');
|
|
79
|
+
return `sha384-${hash}`;
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CheerioAPI } from 'cheerio';
|
|
2
|
+
|
|
3
|
+
interface LazyOptions {
|
|
4
|
+
readonly skip: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const DEFAULT_OPTIONS: LazyOptions = {
|
|
8
|
+
skip: 1
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function applyLazyLoading(document: CheerioAPI, options: LazyOptions = DEFAULT_OPTIONS): void {
|
|
12
|
+
const { skip } = options;
|
|
13
|
+
let index = 0;
|
|
14
|
+
document('img').each((_i, element) => {
|
|
15
|
+
const img = document(element);
|
|
16
|
+
if (img.attr('loading')) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
index += 1;
|
|
21
|
+
if (index <= skip) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
img.attr('loading', 'lazy');
|
|
26
|
+
if (!img.attr('fetchpriority')) {
|
|
27
|
+
img.attr('fetchpriority', 'low');
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { FOLDERS, FILES, EXTENSIONS } from '../core/constants.js';
|
|
3
|
+
import { ensureDir, pathExists, writeFile } from '../utils/fs.js';
|
|
4
|
+
|
|
5
|
+
export interface PageScaffoldOptions {
|
|
6
|
+
readonly workspaceRoot: string;
|
|
7
|
+
readonly pageName: string;
|
|
8
|
+
readonly mode?: 'standard' | 'ssg';
|
|
9
|
+
readonly paths: {
|
|
10
|
+
readonly pages: string;
|
|
11
|
+
readonly app: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function createPageScaffold(options: PageScaffoldOptions): Promise<void> {
|
|
16
|
+
const pageDir = path.join(options.paths.pages, options.pageName);
|
|
17
|
+
if (await pathExists(pageDir)) {
|
|
18
|
+
throw new Error(`Page '${options.pageName}' already exists.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
await ensureDir(pageDir);
|
|
22
|
+
|
|
23
|
+
const mode = options.mode ?? 'standard';
|
|
24
|
+
const writes: Promise<void>[] = [
|
|
25
|
+
writeFile(path.join(pageDir, `${FILES.index}${EXTENSIONS.html}`), buildHtmlTemplate(options.pageName, mode)),
|
|
26
|
+
writeFile(path.join(pageDir, `${FILES.index}${EXTENSIONS.css}`), buildCssTemplate(options.pageName))
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
if (mode === 'standard') {
|
|
30
|
+
writes.push(writeFile(path.join(pageDir, `${FILES.index}${EXTENSIONS.ts}`), buildScriptTemplate()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await Promise.all(writes);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildHtmlTemplate(pageName: string, mode: 'standard' | 'ssg'): string {
|
|
37
|
+
const script = mode === 'standard'
|
|
38
|
+
? ` <script type="module" src="${FILES.index}${EXTENSIONS.js}" async></script>`
|
|
39
|
+
: ` <!-- Add ${FILES.index}${EXTENSIONS.ts} to enable JS on this page. -->`;
|
|
40
|
+
|
|
41
|
+
return `<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<title>${pageName}</title>
|
|
44
|
+
<link rel="stylesheet" href="${FILES.index}${EXTENSIONS.css}">
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<main>
|
|
48
|
+
<h1>${pageName}</h1>
|
|
49
|
+
<p>Content for the ${pageName} page.</p>
|
|
50
|
+
</main>
|
|
51
|
+
${script}
|
|
52
|
+
</body>
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildCssTemplate(pageName: string): string {
|
|
57
|
+
return `/* ${pageName} Page Styles */
|
|
58
|
+
@import "@app/app.css";
|
|
59
|
+
|
|
60
|
+
/* Add your page-specific styles here */
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildScriptTemplate(): string {
|
|
65
|
+
return `// Page entry point
|
|
66
|
+
import '../../app/app';
|
|
67
|
+
|
|
68
|
+
// Add page-specific logic here
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CheerioAPI } from 'cheerio';
|
|
2
|
+
import { resolvePageHtmlUrl } from '../utils/pagePaths.js';
|
|
3
|
+
|
|
4
|
+
export interface ResourceHintResult {
|
|
5
|
+
readonly added: number;
|
|
6
|
+
readonly candidates: string[];
|
|
7
|
+
readonly missingHead: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function injectResourceHints(
|
|
11
|
+
document: CheerioAPI,
|
|
12
|
+
currentPage: string,
|
|
13
|
+
pagesUrlPrefix: string,
|
|
14
|
+
useRootIndex: boolean
|
|
15
|
+
): ResourceHintResult {
|
|
16
|
+
const head = document('head').first();
|
|
17
|
+
const pages = [...collectInternalPages(document, currentPage, pagesUrlPrefix)];
|
|
18
|
+
|
|
19
|
+
if (head.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
added: 0,
|
|
22
|
+
candidates: pages,
|
|
23
|
+
missingHead: pages.length > 0
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (pages.length === 0) {
|
|
28
|
+
return { added: 0, candidates: [], missingHead: false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const page of pages) {
|
|
32
|
+
const href = resolvePageHtmlUrl(pagesUrlPrefix, page, useRootIndex);
|
|
33
|
+
head.append(`\n<link rel="prefetch" href="${href}" as="document">`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { added: pages.length, candidates: pages, missingHead: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collectInternalPages(document: CheerioAPI, currentPage: string, pagesUrlPrefix: string): Set<string> {
|
|
40
|
+
const pages = new Set<string>();
|
|
41
|
+
document('a[href]').each((_index, element) => {
|
|
42
|
+
const href = document(element).attr('href');
|
|
43
|
+
const pageName = normalizePageName(href, pagesUrlPrefix);
|
|
44
|
+
if (!pageName || pageName === currentPage) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
pages.add(pageName);
|
|
48
|
+
});
|
|
49
|
+
return pages;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizePageName(href: string | undefined, pagesUrlPrefix: string): string | null {
|
|
53
|
+
if (!href || href.length === 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lower = href.toLowerCase();
|
|
58
|
+
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:') || lower.startsWith('#')) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let path = href.split('#')[0]?.split('?')[0] ?? '';
|
|
63
|
+
if (path.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (path.startsWith('/')) {
|
|
68
|
+
path = path.slice(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const prefix = trimSlashes(pagesUrlPrefix);
|
|
72
|
+
if (prefix && path.startsWith(`${prefix}/`)) {
|
|
73
|
+
path = path.slice(prefix.length + 1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (path.endsWith('/')) {
|
|
77
|
+
path = path.slice(0, -1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const segments = path.split('/');
|
|
81
|
+
const candidate = segments[0];
|
|
82
|
+
if (!candidate) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return candidate;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function trimSlashes(value: string): string {
|
|
90
|
+
return value.replace(/^\/+|\/+$/g, '');
|
|
91
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readJson, writeJson } from '../../utils/fs.js';
|
|
3
|
+
import type { WorkspaceModuleView, WorkspacePackageJson } from '../../config/workspaceManifest.js';
|
|
4
|
+
|
|
5
|
+
export interface SsgViewMetadataOptions {
|
|
6
|
+
readonly workspaceRoot: string;
|
|
7
|
+
readonly pageName: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function ensureSsgViewMetadataForPage(options: SsgViewMetadataOptions): Promise<void> {
|
|
11
|
+
const pkgPath = path.join(options.workspaceRoot, 'package.json');
|
|
12
|
+
const pkg = (await readJson<WorkspacePackageJson>(pkgPath)) ?? {};
|
|
13
|
+
const workspaceMode = pkg.webstir?.mode;
|
|
14
|
+
const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg';
|
|
15
|
+
if (isSsgWorkspace) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const webstir = pkg.webstir ?? {};
|
|
20
|
+
const moduleConfig = webstir.moduleManifest ?? {};
|
|
21
|
+
const existingViews = Array.isArray(moduleConfig.views) ? [...moduleConfig.views] : [];
|
|
22
|
+
|
|
23
|
+
const pageName = options.pageName;
|
|
24
|
+
const isHome = pageName === 'home';
|
|
25
|
+
const viewName = `${capitalize(pageName)}View`;
|
|
26
|
+
const viewPath = isHome ? '/' : `/${pageName}`;
|
|
27
|
+
|
|
28
|
+
const existingIndex = existingViews.findIndex((view) => view?.name === viewName || view?.path === viewPath);
|
|
29
|
+
const existing = existingIndex >= 0 ? (existingViews[existingIndex] ?? {}) : {};
|
|
30
|
+
const nextView: WorkspaceModuleView = {
|
|
31
|
+
...existing,
|
|
32
|
+
name: viewName,
|
|
33
|
+
path: viewPath,
|
|
34
|
+
renderMode: 'ssg',
|
|
35
|
+
staticPaths: [viewPath]
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (existingIndex >= 0) {
|
|
39
|
+
existingViews[existingIndex] = nextView;
|
|
40
|
+
} else {
|
|
41
|
+
existingViews.push(nextView);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const nextPkg: WorkspacePackageJson = {
|
|
45
|
+
...pkg,
|
|
46
|
+
webstir: {
|
|
47
|
+
...webstir,
|
|
48
|
+
moduleManifest: {
|
|
49
|
+
...moduleConfig,
|
|
50
|
+
views: existingViews
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
await writeJson(pkgPath, nextPkg);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function capitalize(value: string): string {
|
|
59
|
+
if (!value) {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
63
|
+
}
|