@webstir-io/webstir-frontend 0.1.40 → 0.1.41

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.
Files changed (138) hide show
  1. package/README.md +124 -60
  2. package/dist/assets/imageOptimizer.js +10 -15
  3. package/dist/assets/precompression.js +1 -1
  4. package/dist/builders/contentBuilder.js +102 -90
  5. package/dist/builders/cssBuilder.js +25 -19
  6. package/dist/builders/htmlBuilder.js +57 -42
  7. package/dist/builders/index.js +1 -1
  8. package/dist/builders/jsBuilder.js +219 -76
  9. package/dist/builders/staticAssetsBuilder.js +27 -9
  10. package/dist/builders/types.d.ts +1 -0
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +6 -30
  13. package/dist/config/manifest.js +7 -6
  14. package/dist/config/paths.js +2 -2
  15. package/dist/config/schema.d.ts +8 -0
  16. package/dist/config/schema.js +7 -6
  17. package/dist/config/setup.js +1 -1
  18. package/dist/config/workspace.js +11 -9
  19. package/dist/core/constants.d.ts +1 -1
  20. package/dist/core/constants.js +5 -5
  21. package/dist/core/diagnostics.js +1 -1
  22. package/dist/core/pages.js +4 -4
  23. package/dist/hooks.js +3 -3
  24. package/dist/html/criticalCss.js +6 -3
  25. package/dist/html/htmlSecurity.d.ts +6 -1
  26. package/dist/html/htmlSecurity.js +28 -14
  27. package/dist/html/lazyLoad.js +1 -1
  28. package/dist/html/pageScaffold.js +1 -1
  29. package/dist/html/resourceHints.js +5 -2
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +2 -0
  32. package/dist/inspect.d.ts +2 -0
  33. package/dist/inspect.js +110 -0
  34. package/dist/modes/ssg/metadata.js +4 -4
  35. package/dist/modes/ssg/routing.js +2 -5
  36. package/dist/modes/ssg/seo.js +5 -5
  37. package/dist/modes/ssg/views.js +17 -11
  38. package/dist/operations.js +18 -10
  39. package/dist/pipeline.d.ts +1 -0
  40. package/dist/pipeline.js +6 -1
  41. package/dist/provider.js +28 -24
  42. package/dist/runtime/boundary.d.ts +28 -0
  43. package/dist/runtime/boundary.js +247 -0
  44. package/dist/runtime/index.d.ts +1 -0
  45. package/dist/runtime/index.js +1 -0
  46. package/dist/types.d.ts +52 -0
  47. package/dist/utils/fs.d.ts +11 -10
  48. package/dist/utils/fs.js +48 -20
  49. package/dist/utils/glob.d.ts +8 -0
  50. package/dist/utils/glob.js +21 -0
  51. package/dist/utils/hash.js +1 -2
  52. package/dist/utils/pagePaths.js +2 -2
  53. package/package.json +19 -14
  54. package/scripts/publish.sh +2 -94
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/assets/assetManifest.ts +39 -29
  57. package/src/assets/imageOptimizer.ts +91 -82
  58. package/src/assets/precompression.ts +22 -16
  59. package/src/builders/contentBuilder.ts +1224 -1149
  60. package/src/builders/cssBuilder.ts +466 -417
  61. package/src/builders/htmlBuilder.ts +511 -448
  62. package/src/builders/index.ts +7 -7
  63. package/src/builders/jsBuilder.ts +538 -280
  64. package/src/builders/staticAssetsBuilder.ts +166 -135
  65. package/src/builders/types.ts +7 -6
  66. package/src/cli.ts +66 -90
  67. package/src/config/manifest.ts +16 -14
  68. package/src/config/paths.ts +5 -5
  69. package/src/config/schema.ts +38 -37
  70. package/src/config/setup.ts +7 -7
  71. package/src/config/workspace.ts +118 -116
  72. package/src/config/workspaceManifest.ts +14 -14
  73. package/src/core/constants.ts +62 -62
  74. package/src/core/diagnostics.ts +26 -26
  75. package/src/core/pages.ts +19 -19
  76. package/src/hooks.ts +128 -118
  77. package/src/html/criticalCss.ts +84 -77
  78. package/src/html/htmlSecurity.ts +107 -66
  79. package/src/html/lazyLoad.ts +22 -19
  80. package/src/html/pageScaffold.ts +37 -28
  81. package/src/html/resourceHints.ts +83 -74
  82. package/src/index.ts +2 -0
  83. package/src/inspect.ts +158 -0
  84. package/src/modes/ssg/metadata.ts +53 -51
  85. package/src/modes/ssg/routing.ts +177 -177
  86. package/src/modes/ssg/seo.ts +208 -200
  87. package/src/modes/ssg/validation.ts +31 -25
  88. package/src/modes/ssg/views.ts +257 -238
  89. package/src/operations.ts +105 -95
  90. package/src/pipeline.ts +81 -69
  91. package/src/provider.ts +184 -176
  92. package/src/runtime/boundary.ts +325 -0
  93. package/src/runtime/index.ts +1 -0
  94. package/src/types.ts +107 -48
  95. package/src/utils/changedFile.ts +22 -22
  96. package/src/utils/fs.ts +73 -26
  97. package/src/utils/glob.ts +38 -0
  98. package/src/utils/hash.ts +2 -4
  99. package/src/utils/pagePaths.ts +35 -23
  100. package/src/utils/pathMatch.ts +26 -23
  101. package/tests/add-page-defaults.test.js +44 -39
  102. package/tests/bundlerParity.test.js +252 -0
  103. package/tests/cli.contract.test.js +13 -0
  104. package/tests/content-pages.test.js +108 -13
  105. package/tests/css-app-imports.test.js +22 -11
  106. package/tests/css-page-imports.test.js +26 -13
  107. package/tests/diagnostics.test.js +39 -36
  108. package/tests/features.test.js +48 -43
  109. package/tests/hooks.test.js +58 -42
  110. package/tests/htmlSecurity.test.js +66 -0
  111. package/tests/inspect.test.js +148 -0
  112. package/tests/provider.integration.test.js +71 -20
  113. package/tests/runtime.test.js +493 -0
  114. package/tests/ssg-defaults.test.js +284 -177
  115. package/tests/ssg-guardrails.test.js +51 -51
  116. package/tsconfig.json +3 -10
  117. package/dist/watch/frontendFiles.d.ts +0 -3
  118. package/dist/watch/frontendFiles.js +0 -25
  119. package/dist/watch/hotUpdateTracker.d.ts +0 -51
  120. package/dist/watch/hotUpdateTracker.js +0 -205
  121. package/dist/watch/pipelineHelpers.d.ts +0 -26
  122. package/dist/watch/pipelineHelpers.js +0 -177
  123. package/dist/watch/types.d.ts +0 -27
  124. package/dist/watch/types.js +0 -1
  125. package/dist/watch/watchCoordinator.d.ts +0 -36
  126. package/dist/watch/watchCoordinator.js +0 -551
  127. package/dist/watch/watchDaemon.d.ts +0 -17
  128. package/dist/watch/watchDaemon.js +0 -127
  129. package/dist/watch/watchReporter.d.ts +0 -21
  130. package/dist/watch/watchReporter.js +0 -64
  131. package/scripts/smoke.mjs +0 -35
  132. package/src/watch/frontendFiles.ts +0 -32
  133. package/src/watch/hotUpdateTracker.ts +0 -285
  134. package/src/watch/pipelineHelpers.ts +0 -242
  135. package/src/watch/types.ts +0 -23
  136. package/src/watch/watchCoordinator.ts +0 -666
  137. package/src/watch/watchDaemon.ts +0 -144
  138. package/src/watch/watchReporter.ts +0 -98
@@ -1,261 +1,269 @@
1
1
  import path from 'node:path';
2
- import { glob } from 'glob';
3
2
  import { load } from 'cheerio';
4
3
  import { ensureDir, pathExists, readFile, writeFile } from '../../utils/fs.js';
5
4
  import { FILES } from '../../core/constants.js';
5
+ import { scanGlob } from '../../utils/glob.js';
6
6
 
7
7
  interface HtmlPage {
8
- readonly filePath: string;
9
- readonly urlPath: string;
8
+ readonly filePath: string;
9
+ readonly urlPath: string;
10
10
  }
11
11
 
12
12
  export interface SsgSeoOptions {
13
- readonly siteUrl?: string;
13
+ readonly siteUrl?: string;
14
14
  }
15
15
 
16
16
  export async function runSsgSeo(distRoot: string, options: SsgSeoOptions = {}): Promise<void> {
17
- const pages = await discoverHtmlPages(distRoot);
18
- await validateInternalLinks(pages, distRoot);
19
- await writeSitemap(distRoot, pages, options.siteUrl);
20
- await writeRobots(distRoot, options.siteUrl);
17
+ const pages = await discoverHtmlPages(distRoot);
18
+ await validateInternalLinks(pages, distRoot);
19
+ await writeSitemap(distRoot, pages, options.siteUrl);
20
+ await writeRobots(distRoot, options.siteUrl);
21
21
  }
22
22
 
23
23
  async function discoverHtmlPages(distRoot: string): Promise<HtmlPage[]> {
24
- if (!(await pathExists(distRoot))) {
25
- return [];
26
- }
27
-
28
- const files = await glob('**/index.html', { cwd: distRoot, nodir: true, ignore: ['pages/**'] });
29
- const pages = files
30
- .map((relative) => {
31
- const normalized = relative.split(path.sep).join('/');
32
- const urlPath = toUrlPath(normalized);
33
- return {
34
- filePath: path.join(distRoot, relative),
35
- urlPath
36
- };
37
- })
38
- .filter((page) => Boolean(page.urlPath));
39
-
40
- pages.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
41
- return pages;
24
+ if (!(await pathExists(distRoot))) {
25
+ return [];
26
+ }
27
+
28
+ const files = (await scanGlob('**/index.html', { cwd: distRoot })).filter(
29
+ (relative) => !relative.split(path.sep).join('/').startsWith('pages/'),
30
+ );
31
+ const pages = files
32
+ .map((relative) => {
33
+ const normalized = relative.split(path.sep).join('/');
34
+ const urlPath = toUrlPath(normalized);
35
+ return {
36
+ filePath: path.join(distRoot, relative),
37
+ urlPath,
38
+ };
39
+ })
40
+ .filter((page) => Boolean(page.urlPath));
41
+
42
+ pages.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
43
+ return pages;
42
44
  }
43
45
 
44
46
  function toUrlPath(relativeIndex: string): string {
45
- if (relativeIndex === FILES.indexHtml) {
46
- return '/';
47
- }
47
+ if (relativeIndex === FILES.indexHtml) {
48
+ return '/';
49
+ }
48
50
 
49
- if (relativeIndex.endsWith(`/${FILES.indexHtml}`)) {
50
- return `/${relativeIndex.slice(0, -FILES.indexHtml.length)}`;
51
- }
51
+ if (relativeIndex.endsWith(`/${FILES.indexHtml}`)) {
52
+ return `/${relativeIndex.slice(0, -FILES.indexHtml.length)}`;
53
+ }
52
54
 
53
- return '';
55
+ return '';
54
56
  }
55
57
 
56
58
  async function validateInternalLinks(pages: readonly HtmlPage[], distRoot: string): Promise<void> {
57
- if (pages.length === 0) {
58
- return;
59
- }
60
-
61
- const knownPages = new Set<string>(pages.map((page) => page.urlPath));
62
- const idsByPath = await collectIdsByPath(pages);
63
- const errors: string[] = [];
64
-
65
- for (const page of pages) {
66
- const html = await readFile(page.filePath);
67
- const doc = load(html);
68
- const anchors = doc('a[href]').toArray();
69
-
70
- for (const element of anchors) {
71
- const rawHref = doc(element).attr('href')?.trim() ?? '';
72
- if (!rawHref) {
73
- continue;
74
- }
75
-
76
- if (isExternalHref(rawHref)) {
77
- continue;
78
- }
79
-
80
- const resolved = resolveHref(page.urlPath, rawHref);
81
- if (!resolved) {
82
- continue;
83
- }
84
-
85
- const normalizedPath = normalizePagePath(resolved.pathname, knownPages);
86
- if (!normalizedPath) {
87
- // Still allow links to known static assets (best-effort).
88
- if (await targetExistsInDist(distRoot, resolved.pathname)) {
89
- continue;
90
- }
91
-
92
- errors.push(`${page.filePath}: broken link '${rawHref}'`);
93
- continue;
94
- }
95
-
96
- const hash = resolved.hash.startsWith('#') ? resolved.hash.slice(1) : resolved.hash;
97
- if (!hash) {
98
- continue;
99
- }
100
-
101
- const ids = idsByPath.get(normalizedPath) ?? new Set<string>();
102
- if (!ids.has(hash)) {
103
- errors.push(`${page.filePath}: broken anchor '${rawHref}' (missing '#${hash}' on ${normalizedPath})`);
104
- }
59
+ if (pages.length === 0) {
60
+ return;
61
+ }
62
+
63
+ const knownPages = new Set<string>(pages.map((page) => page.urlPath));
64
+ const idsByPath = await collectIdsByPath(pages);
65
+ const errors: string[] = [];
66
+
67
+ for (const page of pages) {
68
+ const html = await readFile(page.filePath);
69
+ const doc = load(html);
70
+ const anchors = doc('a[href]').toArray();
71
+
72
+ for (const element of anchors) {
73
+ const rawHref = doc(element).attr('href')?.trim() ?? '';
74
+ if (!rawHref) {
75
+ continue;
76
+ }
77
+
78
+ if (isExternalHref(rawHref)) {
79
+ continue;
80
+ }
81
+
82
+ const resolved = resolveHref(page.urlPath, rawHref);
83
+ if (!resolved) {
84
+ continue;
85
+ }
86
+
87
+ const normalizedPath = normalizePagePath(resolved.pathname, knownPages);
88
+ if (!normalizedPath) {
89
+ // Still allow links to known static assets (best-effort).
90
+ if (await targetExistsInDist(distRoot, resolved.pathname)) {
91
+ continue;
105
92
  }
106
- }
107
93
 
108
- if (errors.length === 0) {
109
- return;
94
+ errors.push(`${page.filePath}: broken link '${rawHref}'`);
95
+ continue;
96
+ }
97
+
98
+ const hash = resolved.hash.startsWith('#') ? resolved.hash.slice(1) : resolved.hash;
99
+ if (!hash) {
100
+ continue;
101
+ }
102
+
103
+ const ids = idsByPath.get(normalizedPath) ?? new Set<string>();
104
+ if (!ids.has(hash)) {
105
+ errors.push(
106
+ `${page.filePath}: broken anchor '${rawHref}' (missing '#${hash}' on ${normalizedPath})`,
107
+ );
108
+ }
110
109
  }
110
+ }
111
+
112
+ if (errors.length === 0) {
113
+ return;
114
+ }
111
115
 
112
- const preview = errors.slice(0, 16).join('\n');
113
- const suffix = errors.length > 16 ? `\n… and ${errors.length - 16} more.` : '';
114
- throw new Error(`Broken links found in publish output:\n${preview}${suffix}`);
116
+ const preview = errors.slice(0, 16).join('\n');
117
+ const suffix = errors.length > 16 ? `\n… and ${errors.length - 16} more.` : '';
118
+ throw new Error(`Broken links found in publish output:\n${preview}${suffix}`);
115
119
  }
116
120
 
117
121
  async function collectIdsByPath(pages: readonly HtmlPage[]): Promise<Map<string, Set<string>>> {
118
- const idsByPath = new Map<string, Set<string>>();
119
-
120
- for (const page of pages) {
121
- const html = await readFile(page.filePath);
122
- const doc = load(html);
123
- const ids = new Set<string>();
124
-
125
- doc('[id]').each((_, element) => {
126
- const raw = doc(element).attr('id');
127
- const value = typeof raw === 'string' ? raw.trim() : '';
128
- if (value) {
129
- ids.add(value);
130
- }
131
- });
132
-
133
- idsByPath.set(page.urlPath, ids);
134
- }
122
+ const idsByPath = new Map<string, Set<string>>();
123
+
124
+ for (const page of pages) {
125
+ const html = await readFile(page.filePath);
126
+ const doc = load(html);
127
+ const ids = new Set<string>();
128
+
129
+ doc('[id]').each((_, element) => {
130
+ const raw = doc(element).attr('id');
131
+ const value = typeof raw === 'string' ? raw.trim() : '';
132
+ if (value) {
133
+ ids.add(value);
134
+ }
135
+ });
136
+
137
+ idsByPath.set(page.urlPath, ids);
138
+ }
135
139
 
136
- return idsByPath;
140
+ return idsByPath;
137
141
  }
138
142
 
139
143
  function isExternalHref(href: string): boolean {
140
- if (href.startsWith('//')) {
141
- return true;
142
- }
143
- return /^[a-z][a-z0-9+.-]*:/i.test(href);
144
+ if (href.startsWith('//')) {
145
+ return true;
146
+ }
147
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
144
148
  }
145
149
 
146
150
  function resolveHref(basePath: string, href: string): URL | null {
147
- try {
148
- const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
149
- return new URL(href, `http://webstir.local${base}`);
150
- } catch {
151
- return null;
152
- }
151
+ try {
152
+ const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
153
+ return new URL(href, `http://webstir.local${base}`);
154
+ } catch {
155
+ return null;
156
+ }
153
157
  }
154
158
 
155
159
  function normalizePagePath(pathname: string, knownPages: ReadonlySet<string>): string | null {
156
- if (pathname === '/' || pathname === '') {
157
- return '/';
158
- }
160
+ if (pathname === '/' || pathname === '') {
161
+ return '/';
162
+ }
159
163
 
160
- if (pathname.endsWith('/index.html')) {
161
- const asDir = pathname.slice(0, -'index.html'.length);
162
- return knownPages.has(asDir) ? asDir : null;
163
- }
164
+ if (pathname.endsWith('/index.html')) {
165
+ const asDir = pathname.slice(0, -'index.html'.length);
166
+ return knownPages.has(asDir) ? asDir : null;
167
+ }
164
168
 
165
- if (pathname.endsWith('.html')) {
166
- return knownPages.has(pathname) ? pathname : null;
167
- }
169
+ if (pathname.endsWith('.html')) {
170
+ return knownPages.has(pathname) ? pathname : null;
171
+ }
168
172
 
169
- if (pathname.endsWith('/')) {
170
- return knownPages.has(pathname) ? pathname : null;
171
- }
173
+ if (pathname.endsWith('/')) {
174
+ return knownPages.has(pathname) ? pathname : null;
175
+ }
172
176
 
173
- const withSlash = `${pathname}/`;
174
- if (knownPages.has(withSlash)) {
175
- return withSlash;
176
- }
177
+ const withSlash = `${pathname}/`;
178
+ if (knownPages.has(withSlash)) {
179
+ return withSlash;
180
+ }
177
181
 
178
- return knownPages.has(pathname) ? pathname : null;
182
+ return knownPages.has(pathname) ? pathname : null;
179
183
  }
180
184
 
181
185
  async function targetExistsInDist(distRoot: string, pathname: string): Promise<boolean> {
182
- if (!pathname.startsWith('/')) {
183
- return false;
184
- }
185
-
186
- const relative = pathname.replace(/^\/+/, '');
187
- if (!relative) {
188
- return true;
189
- }
190
-
191
- const full = path.join(distRoot, relative);
192
- if (await pathExists(full)) {
193
- return true;
194
- }
195
-
196
- const asIndex = path.join(distRoot, relative, FILES.indexHtml);
197
- return pathExists(asIndex);
186
+ if (!pathname.startsWith('/')) {
187
+ return false;
188
+ }
189
+
190
+ const relative = pathname.replace(/^\/+/, '');
191
+ if (!relative) {
192
+ return true;
193
+ }
194
+
195
+ const full = path.join(distRoot, relative);
196
+ if (await pathExists(full)) {
197
+ return true;
198
+ }
199
+
200
+ const asIndex = path.join(distRoot, relative, FILES.indexHtml);
201
+ return pathExists(asIndex);
198
202
  }
199
203
 
200
- async function writeSitemap(distRoot: string, pages: readonly HtmlPage[], siteUrl?: string): Promise<void> {
201
- const urls = pages.map((page) => page.urlPath).filter((url) => url.startsWith('/'));
202
- const unique = Array.from(new Set(urls)).sort((a, b) => a.localeCompare(b));
203
-
204
- const baseUrl = normalizeSiteUrl(siteUrl);
205
- const comment = baseUrl ? '' : '<!-- Set WEBSTIR_SITE_URL to emit absolute <loc> entries. -->\n';
206
-
207
- const entries = unique
208
- .map((pathname) => {
209
- const loc = baseUrl ? new URL(pathname, baseUrl).href : pathname;
210
- return ` <url><loc>${escapeXml(loc)}</loc></url>`;
211
- })
212
- .join('\n');
213
-
214
- const xml = [
215
- '<?xml version="1.0" encoding="UTF-8"?>',
216
- comment + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
217
- entries,
218
- '</urlset>',
219
- ''
220
- ].join('\n');
221
-
222
- const outputPath = path.join(distRoot, 'sitemap.xml');
223
- await ensureDir(path.dirname(outputPath));
224
- await writeFile(outputPath, xml);
204
+ async function writeSitemap(
205
+ distRoot: string,
206
+ pages: readonly HtmlPage[],
207
+ siteUrl?: string,
208
+ ): Promise<void> {
209
+ const urls = pages.map((page) => page.urlPath).filter((url) => url.startsWith('/'));
210
+ const unique = Array.from(new Set(urls)).sort((a, b) => a.localeCompare(b));
211
+
212
+ const baseUrl = normalizeSiteUrl(siteUrl);
213
+ const comment = baseUrl ? '' : '<!-- Set WEBSTIR_SITE_URL to emit absolute <loc> entries. -->\n';
214
+
215
+ const entries = unique
216
+ .map((pathname) => {
217
+ const loc = baseUrl ? new URL(pathname, baseUrl).href : pathname;
218
+ return ` <url><loc>${escapeXml(loc)}</loc></url>`;
219
+ })
220
+ .join('\n');
221
+
222
+ const xml = [
223
+ '<?xml version="1.0" encoding="UTF-8"?>',
224
+ `${comment}<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
225
+ entries,
226
+ '</urlset>',
227
+ '',
228
+ ].join('\n');
229
+
230
+ const outputPath = path.join(distRoot, 'sitemap.xml');
231
+ await ensureDir(path.dirname(outputPath));
232
+ await writeFile(outputPath, xml);
225
233
  }
226
234
 
227
235
  async function writeRobots(distRoot: string, siteUrl?: string): Promise<void> {
228
- const baseUrl = normalizeSiteUrl(siteUrl);
229
- const sitemapLine = baseUrl ? `\nSitemap: ${new URL('/sitemap.xml', baseUrl).href}` : '';
230
- const content = `User-agent: *\nAllow: /${sitemapLine}\n`;
236
+ const baseUrl = normalizeSiteUrl(siteUrl);
237
+ const sitemapLine = baseUrl ? `\nSitemap: ${new URL('/sitemap.xml', baseUrl).href}` : '';
238
+ const content = `User-agent: *\nAllow: /${sitemapLine}\n`;
231
239
 
232
- const outputPath = path.join(distRoot, FILES.robotsTxt);
233
- await ensureDir(path.dirname(outputPath));
234
- await writeFile(outputPath, content);
240
+ const outputPath = path.join(distRoot, FILES.robotsTxt);
241
+ await ensureDir(path.dirname(outputPath));
242
+ await writeFile(outputPath, content);
235
243
  }
236
244
 
237
245
  function normalizeSiteUrl(value: string | undefined): string | undefined {
238
- const trimmed = (value ?? '').trim();
239
- if (!trimmed) {
240
- return undefined;
241
- }
242
-
243
- try {
244
- const url = new URL(trimmed);
245
- url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
246
- url.hash = '';
247
- url.search = '';
248
- return url.toString();
249
- } catch {
250
- return undefined;
251
- }
246
+ const trimmed = (value ?? '').trim();
247
+ if (!trimmed) {
248
+ return undefined;
249
+ }
250
+
251
+ try {
252
+ const url = new URL(trimmed);
253
+ url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
254
+ url.hash = '';
255
+ url.search = '';
256
+ return url.toString();
257
+ } catch {
258
+ return undefined;
259
+ }
252
260
  }
253
261
 
254
262
  function escapeXml(value: string): string {
255
- return value
256
- .replace(/&/g, '&amp;')
257
- .replace(/</g, '&lt;')
258
- .replace(/>/g, '&gt;')
259
- .replace(/"/g, '&quot;')
260
- .replace(/'/g, '&apos;');
263
+ return value
264
+ .replace(/&/g, '&amp;')
265
+ .replace(/</g, '&lt;')
266
+ .replace(/>/g, '&gt;')
267
+ .replace(/"/g, '&quot;')
268
+ .replace(/'/g, '&apos;');
261
269
  }
@@ -1,37 +1,43 @@
1
1
  import path from 'node:path';
2
2
  import { readJson } from '../../utils/fs.js';
3
- import type { WorkspaceModuleConfig, WorkspacePackageJson } from '../../config/workspaceManifest.js';
3
+ import type {
4
+ WorkspaceModuleConfig,
5
+ WorkspacePackageJson,
6
+ } from '../../config/workspaceManifest.js';
4
7
 
5
- export function assertNoSsgRoutesInModuleConfig(moduleConfig: WorkspaceModuleConfig | undefined): void {
6
- const routes = moduleConfig?.routes ?? [];
7
- if (!Array.isArray(routes) || routes.length === 0) {
8
- return;
9
- }
8
+ export function assertNoSsgRoutesInModuleConfig(
9
+ moduleConfig: WorkspaceModuleConfig | undefined,
10
+ ): void {
11
+ const routes = moduleConfig?.routes ?? [];
12
+ if (!Array.isArray(routes) || routes.length === 0) {
13
+ return;
14
+ }
10
15
 
11
- const hasSsgRoute = routes.some((route) => {
12
- if (!route || typeof route !== 'object') {
13
- return false;
14
- }
16
+ const hasSsgRoute = routes.some((route) => {
17
+ if (!route || typeof route !== 'object') {
18
+ return false;
19
+ }
15
20
 
16
- const renderMode = typeof route.renderMode === 'string' ? route.renderMode.toLowerCase() : undefined;
17
- const hasStaticPaths = Array.isArray(route.staticPaths) && route.staticPaths.length > 0;
18
- const hasSsgBlock = route.ssg !== undefined;
21
+ const renderMode =
22
+ typeof route.renderMode === 'string' ? route.renderMode.toLowerCase() : undefined;
23
+ const hasStaticPaths = Array.isArray(route.staticPaths) && route.staticPaths.length > 0;
24
+ const hasSsgBlock = route.ssg !== undefined;
19
25
 
20
- return renderMode === 'ssg' || hasStaticPaths || hasSsgBlock;
21
- });
26
+ return renderMode === 'ssg' || hasStaticPaths || hasSsgBlock;
27
+ });
22
28
 
23
- if (!hasSsgRoute) {
24
- return;
25
- }
29
+ if (!hasSsgRoute) {
30
+ return;
31
+ }
26
32
 
27
- throw new Error(
28
- "SSG publish expects SSG metadata under `webstir.moduleManifest.views`, not `webstir.moduleManifest.routes`. Move `renderMode: 'ssg'`, `staticPaths`, and/or `ssg` onto the corresponding view definition."
29
- );
33
+ throw new Error(
34
+ "SSG publish expects SSG metadata under `webstir.moduleManifest.views`, not `webstir.moduleManifest.routes`. Move `renderMode: 'ssg'`, `staticPaths`, and/or `ssg` onto the corresponding view definition.",
35
+ );
30
36
  }
31
37
 
32
38
  export async function assertNoSsgRoutes(workspaceRoot: string): Promise<void> {
33
- const pkgPath = path.join(workspaceRoot, 'package.json');
34
- const pkg = await readJson<WorkspacePackageJson>(pkgPath);
35
- const moduleConfig = pkg?.webstir?.moduleManifest;
36
- assertNoSsgRoutesInModuleConfig(moduleConfig);
39
+ const pkgPath = path.join(workspaceRoot, 'package.json');
40
+ const pkg = await readJson<WorkspacePackageJson>(pkgPath);
41
+ const moduleConfig = pkg?.webstir?.moduleManifest;
42
+ assertNoSsgRoutesInModuleConfig(moduleConfig);
37
43
  }