@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.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/dist/assets/assetManifest.d.ts +16 -0
  4. package/dist/assets/assetManifest.js +31 -0
  5. package/dist/assets/imageOptimizer.d.ts +6 -0
  6. package/dist/assets/imageOptimizer.js +93 -0
  7. package/dist/assets/precompression.d.ts +1 -0
  8. package/dist/assets/precompression.js +21 -0
  9. package/dist/builders/contentBuilder.d.ts +2 -0
  10. package/dist/builders/contentBuilder.js +1052 -0
  11. package/dist/builders/cssBuilder.d.ts +2 -0
  12. package/dist/builders/cssBuilder.js +439 -0
  13. package/dist/builders/htmlBuilder.d.ts +2 -0
  14. package/dist/builders/htmlBuilder.js +430 -0
  15. package/dist/builders/index.d.ts +2 -0
  16. package/dist/builders/index.js +14 -0
  17. package/dist/builders/jsBuilder.d.ts +2 -0
  18. package/dist/builders/jsBuilder.js +300 -0
  19. package/dist/builders/staticAssetsBuilder.d.ts +2 -0
  20. package/dist/builders/staticAssetsBuilder.js +158 -0
  21. package/dist/builders/types.d.ts +12 -0
  22. package/dist/builders/types.js +1 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.js +105 -0
  25. package/dist/config/manifest.d.ts +7 -0
  26. package/dist/config/manifest.js +17 -0
  27. package/dist/config/paths.d.ts +3 -0
  28. package/dist/config/paths.js +11 -0
  29. package/dist/config/schema.d.ts +413 -0
  30. package/dist/config/schema.js +44 -0
  31. package/dist/config/setup.d.ts +2 -0
  32. package/dist/config/setup.js +12 -0
  33. package/dist/config/workspace.d.ts +2 -0
  34. package/dist/config/workspace.js +131 -0
  35. package/dist/config/workspaceManifest.d.ts +23 -0
  36. package/dist/config/workspaceManifest.js +1 -0
  37. package/dist/core/constants.d.ts +70 -0
  38. package/dist/core/constants.js +70 -0
  39. package/dist/core/diagnostics.d.ts +15 -0
  40. package/dist/core/diagnostics.js +21 -0
  41. package/dist/core/index.d.ts +3 -0
  42. package/dist/core/index.js +3 -0
  43. package/dist/core/pages.d.ts +6 -0
  44. package/dist/core/pages.js +23 -0
  45. package/dist/hooks.d.ts +19 -0
  46. package/dist/hooks.js +115 -0
  47. package/dist/html/criticalCss.d.ts +4 -0
  48. package/dist/html/criticalCss.js +192 -0
  49. package/dist/html/htmlSecurity.d.ts +5 -0
  50. package/dist/html/htmlSecurity.js +73 -0
  51. package/dist/html/lazyLoad.d.ts +6 -0
  52. package/dist/html/lazyLoad.js +21 -0
  53. package/dist/html/pageScaffold.d.ts +10 -0
  54. package/dist/html/pageScaffold.js +51 -0
  55. package/dist/html/resourceHints.d.ts +7 -0
  56. package/dist/html/resourceHints.js +64 -0
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.js +5 -0
  59. package/dist/modes/ssg/index.d.ts +4 -0
  60. package/dist/modes/ssg/index.js +4 -0
  61. package/dist/modes/ssg/metadata.d.ts +5 -0
  62. package/dist/modes/ssg/metadata.js +50 -0
  63. package/dist/modes/ssg/routing.d.ts +2 -0
  64. package/dist/modes/ssg/routing.js +186 -0
  65. package/dist/modes/ssg/seo.d.ts +4 -0
  66. package/dist/modes/ssg/seo.js +208 -0
  67. package/dist/modes/ssg/validation.d.ts +3 -0
  68. package/dist/modes/ssg/validation.js +27 -0
  69. package/dist/modes/ssg/views.d.ts +2 -0
  70. package/dist/modes/ssg/views.js +236 -0
  71. package/dist/operations.d.ts +5 -0
  72. package/dist/operations.js +102 -0
  73. package/dist/pipeline.d.ts +7 -0
  74. package/dist/pipeline.js +71 -0
  75. package/dist/provider.d.ts +2 -0
  76. package/dist/provider.js +176 -0
  77. package/dist/types.d.ts +61 -0
  78. package/dist/types.js +1 -0
  79. package/dist/utils/changedFile.d.ts +8 -0
  80. package/dist/utils/changedFile.js +26 -0
  81. package/dist/utils/fs.d.ts +11 -0
  82. package/dist/utils/fs.js +39 -0
  83. package/dist/utils/hash.d.ts +1 -0
  84. package/dist/utils/hash.js +5 -0
  85. package/dist/utils/pagePaths.d.ts +5 -0
  86. package/dist/utils/pagePaths.js +36 -0
  87. package/dist/utils/pathMatch.d.ts +3 -0
  88. package/dist/utils/pathMatch.js +29 -0
  89. package/dist/watch/frontendFiles.d.ts +3 -0
  90. package/dist/watch/frontendFiles.js +25 -0
  91. package/dist/watch/hotUpdateTracker.d.ts +51 -0
  92. package/dist/watch/hotUpdateTracker.js +205 -0
  93. package/dist/watch/pipelineHelpers.d.ts +26 -0
  94. package/dist/watch/pipelineHelpers.js +177 -0
  95. package/dist/watch/types.d.ts +27 -0
  96. package/dist/watch/types.js +1 -0
  97. package/dist/watch/watchCoordinator.d.ts +36 -0
  98. package/dist/watch/watchCoordinator.js +551 -0
  99. package/dist/watch/watchDaemon.d.ts +17 -0
  100. package/dist/watch/watchDaemon.js +127 -0
  101. package/dist/watch/watchReporter.d.ts +21 -0
  102. package/dist/watch/watchReporter.js +64 -0
  103. package/package.json +92 -0
  104. package/scripts/publish.sh +101 -0
  105. package/scripts/smoke.mjs +35 -0
  106. package/scripts/update-contract.sh +121 -0
  107. package/src/assets/assetManifest.ts +51 -0
  108. package/src/assets/imageOptimizer.ts +112 -0
  109. package/src/assets/precompression.ts +25 -0
  110. package/src/builders/contentBuilder.ts +1400 -0
  111. package/src/builders/cssBuilder.ts +552 -0
  112. package/src/builders/htmlBuilder.ts +540 -0
  113. package/src/builders/index.ts +16 -0
  114. package/src/builders/jsBuilder.ts +358 -0
  115. package/src/builders/staticAssetsBuilder.ts +174 -0
  116. package/src/builders/types.ts +15 -0
  117. package/src/cli.ts +108 -0
  118. package/src/config/manifest.ts +24 -0
  119. package/src/config/paths.ts +14 -0
  120. package/src/config/schema.ts +49 -0
  121. package/src/config/setup.ts +14 -0
  122. package/src/config/workspace.ts +150 -0
  123. package/src/config/workspaceManifest.ts +27 -0
  124. package/src/core/constants.ts +73 -0
  125. package/src/core/diagnostics.ts +40 -0
  126. package/src/core/index.ts +3 -0
  127. package/src/core/pages.ts +31 -0
  128. package/src/hooks.ts +175 -0
  129. package/src/html/criticalCss.ts +214 -0
  130. package/src/html/htmlSecurity.ts +86 -0
  131. package/src/html/lazyLoad.ts +30 -0
  132. package/src/html/pageScaffold.ts +70 -0
  133. package/src/html/resourceHints.ts +91 -0
  134. package/src/index.ts +5 -0
  135. package/src/modes/ssg/index.ts +4 -0
  136. package/src/modes/ssg/metadata.ts +63 -0
  137. package/src/modes/ssg/routing.ts +230 -0
  138. package/src/modes/ssg/seo.ts +261 -0
  139. package/src/modes/ssg/validation.ts +37 -0
  140. package/src/modes/ssg/views.ts +309 -0
  141. package/src/operations.ts +138 -0
  142. package/src/pipeline.ts +88 -0
  143. package/src/provider.ts +249 -0
  144. package/src/types.ts +67 -0
  145. package/src/utils/changedFile.ts +39 -0
  146. package/src/utils/fs.ts +48 -0
  147. package/src/utils/hash.ts +6 -0
  148. package/src/utils/pagePaths.ts +43 -0
  149. package/src/utils/pathMatch.ts +36 -0
  150. package/src/watch/frontendFiles.ts +32 -0
  151. package/src/watch/hotUpdateTracker.ts +285 -0
  152. package/src/watch/pipelineHelpers.ts +242 -0
  153. package/src/watch/types.ts +23 -0
  154. package/src/watch/watchCoordinator.ts +666 -0
  155. package/src/watch/watchDaemon.ts +144 -0
  156. package/src/watch/watchReporter.ts +98 -0
  157. package/tests/add-page-defaults.test.js +64 -0
  158. package/tests/content-pages.test.js +81 -0
  159. package/tests/css-app-imports.test.js +64 -0
  160. package/tests/css-page-imports.test.js +100 -0
  161. package/tests/diagnostics.test.js +48 -0
  162. package/tests/features.test.js +63 -0
  163. package/tests/hooks.test.js +71 -0
  164. package/tests/provider.integration.test.js +137 -0
  165. package/tests/ssg-defaults.test.js +201 -0
  166. package/tests/ssg-guardrails.test.js +69 -0
  167. package/tsconfig.json +27 -0
@@ -0,0 +1,230 @@
1
+ import path from 'node:path';
2
+ import { glob } from 'glob';
3
+ import { FOLDERS, FILES } from '../../core/constants.js';
4
+ import type { FrontendConfig } from '../../types.js';
5
+ import { copy, ensureDir, pathExists, readJson } from '../../utils/fs.js';
6
+ import { getPageDirectories } from '../../core/pages.js';
7
+ import { assertNoSsgRoutesInModuleConfig } from './validation.js';
8
+ import type { WorkspaceModuleView, WorkspacePackageJson } from '../../config/workspaceManifest.js';
9
+ import { runSsgSeo } from './seo.js';
10
+
11
+ export async function applySsgRouting(config: FrontendConfig): Promise<void> {
12
+ const distRoot = config.paths.dist.frontend;
13
+ const distPagesRoot = config.paths.dist.pages;
14
+ const isRootLayout = path.resolve(distRoot) === path.resolve(distPagesRoot);
15
+
16
+ const pages = await getPageDirectories(distPagesRoot);
17
+ const pageIndexMap = new Map<string, string>();
18
+ const rootIndexPath = path.join(distRoot, FILES.indexHtml);
19
+
20
+ for (const page of pages) {
21
+ const sourceIndex = path.join(page.directory, FILES.indexHtml);
22
+ if (!(await pathExists(sourceIndex))) {
23
+ continue;
24
+ }
25
+
26
+ pageIndexMap.set(page.name, sourceIndex);
27
+
28
+ if (isRootLayout) {
29
+ continue;
30
+ }
31
+
32
+ // For each page, create a /<page>/index.html alias to its main HTML file when available.
33
+ const targetDir = path.join(distRoot, page.name);
34
+ await ensureDir(targetDir);
35
+ const targetIndex = path.join(targetDir, FILES.indexHtml);
36
+ await copy(sourceIndex, targetIndex);
37
+ }
38
+
39
+ if (isRootLayout) {
40
+ if (await pathExists(rootIndexPath)) {
41
+ pageIndexMap.set(FOLDERS.home, rootIndexPath);
42
+ }
43
+ } else {
44
+ // Ensure a root index.html that aliases the home page when present.
45
+ const homeIndexPath = path.join(distPagesRoot, FOLDERS.home, FILES.indexHtml);
46
+ if (await pathExists(homeIndexPath)) {
47
+ await ensureDir(path.dirname(rootIndexPath));
48
+ await copy(homeIndexPath, rootIndexPath);
49
+ }
50
+
51
+ await applyDocsContentAliases(distRoot, distPagesRoot);
52
+ }
53
+
54
+ await applyStaticPathAliases(config, distRoot, distPagesRoot, pageIndexMap);
55
+
56
+ const siteUrl = await resolveWorkspaceSiteUrl(config.paths.workspace);
57
+ await runSsgSeo(distRoot, { siteUrl });
58
+ }
59
+
60
+ async function applyDocsContentAliases(distRoot: string, distPagesRoot: string): Promise<void> {
61
+ if (path.resolve(distRoot) === path.resolve(distPagesRoot)) {
62
+ return;
63
+ }
64
+
65
+ const docsRoot = path.join(distPagesRoot, 'docs');
66
+ if (!(await pathExists(docsRoot))) {
67
+ return;
68
+ }
69
+
70
+ const indexes = await glob('docs/**/index.html', {
71
+ cwd: distPagesRoot,
72
+ nodir: true
73
+ });
74
+
75
+ for (const relativeIndex of indexes) {
76
+ const sourceIndex = path.join(distPagesRoot, relativeIndex);
77
+ if (!(await pathExists(sourceIndex))) {
78
+ continue;
79
+ }
80
+
81
+ const targetIndex = path.join(distRoot, relativeIndex);
82
+ await ensureDir(path.dirname(targetIndex));
83
+ await copy(sourceIndex, targetIndex);
84
+ }
85
+ }
86
+
87
+ async function resolveWorkspaceSiteUrl(workspaceRoot: string): Promise<string | undefined> {
88
+ const fromEnv = process.env.WEBSTIR_SITE_URL?.trim();
89
+ if (fromEnv) {
90
+ return fromEnv;
91
+ }
92
+
93
+ const pkgPath = path.join(workspaceRoot, 'package.json');
94
+ const pkg = await readJson<Record<string, unknown>>(pkgPath);
95
+ const webstir = pkg?.webstir;
96
+ if (!webstir || typeof webstir !== 'object') {
97
+ return undefined;
98
+ }
99
+
100
+ const candidate = (webstir as Record<string, unknown>).siteUrl;
101
+ return typeof candidate === 'string' && candidate.trim() ? candidate.trim() : undefined;
102
+ }
103
+
104
+ async function applyStaticPathAliases(
105
+ config: FrontendConfig,
106
+ distRoot: string,
107
+ distPagesRoot: string,
108
+ pageIndexMap: Map<string, string>
109
+ ): Promise<void> {
110
+ if (pageIndexMap.size === 0) {
111
+ return;
112
+ }
113
+
114
+ const workspaceRoot = config.paths.workspace;
115
+ const pkgPath = path.join(workspaceRoot, 'package.json');
116
+ const pkg = await readJson<WorkspacePackageJson>(pkgPath);
117
+ const workspaceMode = pkg?.webstir?.mode;
118
+ const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg';
119
+ const moduleConfig = pkg?.webstir?.moduleManifest;
120
+ assertNoSsgRoutesInModuleConfig(moduleConfig);
121
+
122
+ const views = moduleConfig?.views ?? [];
123
+ if (views.length === 0) {
124
+ return;
125
+ }
126
+
127
+ for (const view of views) {
128
+ const renderMode = view.renderMode ?? (isSsgWorkspace ? 'ssg' : undefined);
129
+ if (renderMode !== 'ssg') {
130
+ continue;
131
+ }
132
+
133
+ const paths = getEffectiveStaticPaths(view, isSsgWorkspace);
134
+ for (const raw of paths) {
135
+ if (typeof raw !== 'string' || raw.length === 0) {
136
+ continue;
137
+ }
138
+
139
+ const normalized = normalizeStaticPath(raw);
140
+ let sourceIndex: string | undefined;
141
+
142
+ if (normalized === '/') {
143
+ sourceIndex = pageIndexMap.get(FOLDERS.home);
144
+ } else {
145
+ const relativePath = normalized.replace(/^\/+/, '');
146
+ const candidate = path.join(distPagesRoot, relativePath, FILES.indexHtml);
147
+ if (await pathExists(candidate)) {
148
+ sourceIndex = candidate;
149
+ } else {
150
+ const pageName = firstPathSegment(normalized);
151
+ if (!pageName) {
152
+ continue;
153
+ }
154
+ sourceIndex = pageIndexMap.get(pageName);
155
+ }
156
+ }
157
+
158
+ if (!sourceIndex) {
159
+ continue;
160
+ }
161
+
162
+ const targetIndex =
163
+ normalized === '/'
164
+ ? path.join(distRoot, FILES.indexHtml)
165
+ : path.join(distRoot, normalized.replace(/^\/+/, ''), FILES.indexHtml);
166
+
167
+ if (path.resolve(sourceIndex) === path.resolve(targetIndex)) {
168
+ continue;
169
+ }
170
+
171
+ await ensureDir(path.dirname(targetIndex));
172
+ await copy(sourceIndex, targetIndex);
173
+ }
174
+ }
175
+ }
176
+
177
+ function normalizeStaticPath(value: string): string {
178
+ let s = value.trim();
179
+ if (!s.startsWith('/')) {
180
+ s = `/${s}`;
181
+ }
182
+ if (s.length > 1 && s.endsWith('/')) {
183
+ s = s.slice(0, -1);
184
+ }
185
+ return s;
186
+ }
187
+
188
+ function firstPathSegment(pathname: string): string | undefined {
189
+ const [, segment] = pathname.split('/');
190
+ if (!segment) {
191
+ return undefined;
192
+ }
193
+ return segment;
194
+ }
195
+
196
+ function getEffectiveStaticPaths(view: WorkspaceModuleView, isSsgWorkspace: boolean): readonly string[] {
197
+ const explicitPaths = view.staticPaths ?? [];
198
+ if (explicitPaths.length > 0) {
199
+ return explicitPaths;
200
+ }
201
+
202
+ if (!isSsgWorkspace) {
203
+ return [];
204
+ }
205
+
206
+ const candidate = view.path ?? '';
207
+ if (!isDefaultStaticPathCandidate(candidate)) {
208
+ return [];
209
+ }
210
+
211
+ return [candidate];
212
+ }
213
+
214
+ function isDefaultStaticPathCandidate(template: string): boolean {
215
+ if (typeof template !== 'string') {
216
+ return false;
217
+ }
218
+
219
+ const trimmed = template.trim();
220
+ if (!trimmed.startsWith('/')) {
221
+ return false;
222
+ }
223
+
224
+ // Avoid treating parameterized or wildcard templates as a single concrete path.
225
+ if (trimmed.includes(':') || trimmed.includes('*')) {
226
+ return false;
227
+ }
228
+
229
+ return true;
230
+ }
@@ -0,0 +1,261 @@
1
+ import path from 'node:path';
2
+ import { glob } from 'glob';
3
+ import { load } from 'cheerio';
4
+ import { ensureDir, pathExists, readFile, writeFile } from '../../utils/fs.js';
5
+ import { FILES } from '../../core/constants.js';
6
+
7
+ interface HtmlPage {
8
+ readonly filePath: string;
9
+ readonly urlPath: string;
10
+ }
11
+
12
+ export interface SsgSeoOptions {
13
+ readonly siteUrl?: string;
14
+ }
15
+
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);
21
+ }
22
+
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;
42
+ }
43
+
44
+ function toUrlPath(relativeIndex: string): string {
45
+ if (relativeIndex === FILES.indexHtml) {
46
+ return '/';
47
+ }
48
+
49
+ if (relativeIndex.endsWith(`/${FILES.indexHtml}`)) {
50
+ return `/${relativeIndex.slice(0, -FILES.indexHtml.length)}`;
51
+ }
52
+
53
+ return '';
54
+ }
55
+
56
+ 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
+ }
105
+ }
106
+ }
107
+
108
+ if (errors.length === 0) {
109
+ return;
110
+ }
111
+
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}`);
115
+ }
116
+
117
+ 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
+ }
135
+
136
+ return idsByPath;
137
+ }
138
+
139
+ function isExternalHref(href: string): boolean {
140
+ if (href.startsWith('//')) {
141
+ return true;
142
+ }
143
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
144
+ }
145
+
146
+ 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
+ }
153
+ }
154
+
155
+ function normalizePagePath(pathname: string, knownPages: ReadonlySet<string>): string | null {
156
+ if (pathname === '/' || pathname === '') {
157
+ return '/';
158
+ }
159
+
160
+ if (pathname.endsWith('/index.html')) {
161
+ const asDir = pathname.slice(0, -'index.html'.length);
162
+ return knownPages.has(asDir) ? asDir : null;
163
+ }
164
+
165
+ if (pathname.endsWith('.html')) {
166
+ return knownPages.has(pathname) ? pathname : null;
167
+ }
168
+
169
+ if (pathname.endsWith('/')) {
170
+ return knownPages.has(pathname) ? pathname : null;
171
+ }
172
+
173
+ const withSlash = `${pathname}/`;
174
+ if (knownPages.has(withSlash)) {
175
+ return withSlash;
176
+ }
177
+
178
+ return knownPages.has(pathname) ? pathname : null;
179
+ }
180
+
181
+ 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);
198
+ }
199
+
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);
225
+ }
226
+
227
+ 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`;
231
+
232
+ const outputPath = path.join(distRoot, FILES.robotsTxt);
233
+ await ensureDir(path.dirname(outputPath));
234
+ await writeFile(outputPath, content);
235
+ }
236
+
237
+ 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
+ }
252
+ }
253
+
254
+ 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;');
261
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import { readJson } from '../../utils/fs.js';
3
+ import type { WorkspaceModuleConfig, WorkspacePackageJson } from '../../config/workspaceManifest.js';
4
+
5
+ export function assertNoSsgRoutesInModuleConfig(moduleConfig: WorkspaceModuleConfig | undefined): void {
6
+ const routes = moduleConfig?.routes ?? [];
7
+ if (!Array.isArray(routes) || routes.length === 0) {
8
+ return;
9
+ }
10
+
11
+ const hasSsgRoute = routes.some((route) => {
12
+ if (!route || typeof route !== 'object') {
13
+ return false;
14
+ }
15
+
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;
19
+
20
+ return renderMode === 'ssg' || hasStaticPaths || hasSsgBlock;
21
+ });
22
+
23
+ if (!hasSsgRoute) {
24
+ return;
25
+ }
26
+
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
+ );
30
+ }
31
+
32
+ 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);
37
+ }