@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,64 @@
1
+ import { resolvePageHtmlUrl } from '../utils/pagePaths.js';
2
+ export function injectResourceHints(document, currentPage, pagesUrlPrefix, useRootIndex) {
3
+ const head = document('head').first();
4
+ const pages = [...collectInternalPages(document, currentPage, pagesUrlPrefix)];
5
+ if (head.length === 0) {
6
+ return {
7
+ added: 0,
8
+ candidates: pages,
9
+ missingHead: pages.length > 0
10
+ };
11
+ }
12
+ if (pages.length === 0) {
13
+ return { added: 0, candidates: [], missingHead: false };
14
+ }
15
+ for (const page of pages) {
16
+ const href = resolvePageHtmlUrl(pagesUrlPrefix, page, useRootIndex);
17
+ head.append(`\n<link rel="prefetch" href="${href}" as="document">`);
18
+ }
19
+ return { added: pages.length, candidates: pages, missingHead: false };
20
+ }
21
+ function collectInternalPages(document, currentPage, pagesUrlPrefix) {
22
+ const pages = new Set();
23
+ document('a[href]').each((_index, element) => {
24
+ const href = document(element).attr('href');
25
+ const pageName = normalizePageName(href, pagesUrlPrefix);
26
+ if (!pageName || pageName === currentPage) {
27
+ return;
28
+ }
29
+ pages.add(pageName);
30
+ });
31
+ return pages;
32
+ }
33
+ function normalizePageName(href, pagesUrlPrefix) {
34
+ if (!href || href.length === 0) {
35
+ return null;
36
+ }
37
+ const lower = href.toLowerCase();
38
+ if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:') || lower.startsWith('#')) {
39
+ return null;
40
+ }
41
+ let path = href.split('#')[0]?.split('?')[0] ?? '';
42
+ if (path.length === 0) {
43
+ return null;
44
+ }
45
+ if (path.startsWith('/')) {
46
+ path = path.slice(1);
47
+ }
48
+ const prefix = trimSlashes(pagesUrlPrefix);
49
+ if (prefix && path.startsWith(`${prefix}/`)) {
50
+ path = path.slice(prefix.length + 1);
51
+ }
52
+ if (path.endsWith('/')) {
53
+ path = path.slice(0, -1);
54
+ }
55
+ const segments = path.split('/');
56
+ const candidate = segments[0];
57
+ if (!candidate) {
58
+ return null;
59
+ }
60
+ return candidate;
61
+ }
62
+ function trimSlashes(value) {
63
+ return value.replace(/^\/+|\/+$/g, '');
64
+ }
@@ -0,0 +1,5 @@
1
+ export * from './operations.js';
2
+ export * from './config/manifest.js';
3
+ export * from './config/schema.js';
4
+ export * from './types.js';
5
+ export { frontendProvider } from './provider.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './operations.js';
2
+ export * from './config/manifest.js';
3
+ export * from './config/schema.js';
4
+ export * from './types.js';
5
+ export { frontendProvider } from './provider.js';
@@ -0,0 +1,4 @@
1
+ export { applySsgRouting } from './routing.js';
2
+ export { ensureSsgViewMetadataForPage } from './metadata.js';
3
+ export { assertNoSsgRoutes } from './validation.js';
4
+ export { generateSsgViewData } from './views.js';
@@ -0,0 +1,4 @@
1
+ export { applySsgRouting } from './routing.js';
2
+ export { ensureSsgViewMetadataForPage } from './metadata.js';
3
+ export { assertNoSsgRoutes } from './validation.js';
4
+ export { generateSsgViewData } from './views.js';
@@ -0,0 +1,5 @@
1
+ export interface SsgViewMetadataOptions {
2
+ readonly workspaceRoot: string;
3
+ readonly pageName: string;
4
+ }
5
+ export declare function ensureSsgViewMetadataForPage(options: SsgViewMetadataOptions): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import path from 'node:path';
2
+ import { readJson, writeJson } from '../../utils/fs.js';
3
+ export async function ensureSsgViewMetadataForPage(options) {
4
+ const pkgPath = path.join(options.workspaceRoot, 'package.json');
5
+ const pkg = (await readJson(pkgPath)) ?? {};
6
+ const workspaceMode = pkg.webstir?.mode;
7
+ const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg';
8
+ if (isSsgWorkspace) {
9
+ return;
10
+ }
11
+ const webstir = pkg.webstir ?? {};
12
+ const moduleConfig = webstir.moduleManifest ?? {};
13
+ const existingViews = Array.isArray(moduleConfig.views) ? [...moduleConfig.views] : [];
14
+ const pageName = options.pageName;
15
+ const isHome = pageName === 'home';
16
+ const viewName = `${capitalize(pageName)}View`;
17
+ const viewPath = isHome ? '/' : `/${pageName}`;
18
+ const existingIndex = existingViews.findIndex((view) => view?.name === viewName || view?.path === viewPath);
19
+ const existing = existingIndex >= 0 ? (existingViews[existingIndex] ?? {}) : {};
20
+ const nextView = {
21
+ ...existing,
22
+ name: viewName,
23
+ path: viewPath,
24
+ renderMode: 'ssg',
25
+ staticPaths: [viewPath]
26
+ };
27
+ if (existingIndex >= 0) {
28
+ existingViews[existingIndex] = nextView;
29
+ }
30
+ else {
31
+ existingViews.push(nextView);
32
+ }
33
+ const nextPkg = {
34
+ ...pkg,
35
+ webstir: {
36
+ ...webstir,
37
+ moduleManifest: {
38
+ ...moduleConfig,
39
+ views: existingViews
40
+ }
41
+ }
42
+ };
43
+ await writeJson(pkgPath, nextPkg);
44
+ }
45
+ function capitalize(value) {
46
+ if (!value) {
47
+ return value;
48
+ }
49
+ return value.charAt(0).toUpperCase() + value.slice(1);
50
+ }
@@ -0,0 +1,2 @@
1
+ import type { FrontendConfig } from '../../types.js';
2
+ export declare function applySsgRouting(config: FrontendConfig): Promise<void>;
@@ -0,0 +1,186 @@
1
+ import path from 'node:path';
2
+ import { glob } from 'glob';
3
+ import { FOLDERS, FILES } from '../../core/constants.js';
4
+ import { copy, ensureDir, pathExists, readJson } from '../../utils/fs.js';
5
+ import { getPageDirectories } from '../../core/pages.js';
6
+ import { assertNoSsgRoutesInModuleConfig } from './validation.js';
7
+ import { runSsgSeo } from './seo.js';
8
+ export async function applySsgRouting(config) {
9
+ const distRoot = config.paths.dist.frontend;
10
+ const distPagesRoot = config.paths.dist.pages;
11
+ const isRootLayout = path.resolve(distRoot) === path.resolve(distPagesRoot);
12
+ const pages = await getPageDirectories(distPagesRoot);
13
+ const pageIndexMap = new Map();
14
+ const rootIndexPath = path.join(distRoot, FILES.indexHtml);
15
+ for (const page of pages) {
16
+ const sourceIndex = path.join(page.directory, FILES.indexHtml);
17
+ if (!(await pathExists(sourceIndex))) {
18
+ continue;
19
+ }
20
+ pageIndexMap.set(page.name, sourceIndex);
21
+ if (isRootLayout) {
22
+ continue;
23
+ }
24
+ // For each page, create a /<page>/index.html alias to its main HTML file when available.
25
+ const targetDir = path.join(distRoot, page.name);
26
+ await ensureDir(targetDir);
27
+ const targetIndex = path.join(targetDir, FILES.indexHtml);
28
+ await copy(sourceIndex, targetIndex);
29
+ }
30
+ if (isRootLayout) {
31
+ if (await pathExists(rootIndexPath)) {
32
+ pageIndexMap.set(FOLDERS.home, rootIndexPath);
33
+ }
34
+ }
35
+ else {
36
+ // Ensure a root index.html that aliases the home page when present.
37
+ const homeIndexPath = path.join(distPagesRoot, FOLDERS.home, FILES.indexHtml);
38
+ if (await pathExists(homeIndexPath)) {
39
+ await ensureDir(path.dirname(rootIndexPath));
40
+ await copy(homeIndexPath, rootIndexPath);
41
+ }
42
+ await applyDocsContentAliases(distRoot, distPagesRoot);
43
+ }
44
+ await applyStaticPathAliases(config, distRoot, distPagesRoot, pageIndexMap);
45
+ const siteUrl = await resolveWorkspaceSiteUrl(config.paths.workspace);
46
+ await runSsgSeo(distRoot, { siteUrl });
47
+ }
48
+ async function applyDocsContentAliases(distRoot, distPagesRoot) {
49
+ if (path.resolve(distRoot) === path.resolve(distPagesRoot)) {
50
+ return;
51
+ }
52
+ const docsRoot = path.join(distPagesRoot, 'docs');
53
+ if (!(await pathExists(docsRoot))) {
54
+ return;
55
+ }
56
+ const indexes = await glob('docs/**/index.html', {
57
+ cwd: distPagesRoot,
58
+ nodir: true
59
+ });
60
+ for (const relativeIndex of indexes) {
61
+ const sourceIndex = path.join(distPagesRoot, relativeIndex);
62
+ if (!(await pathExists(sourceIndex))) {
63
+ continue;
64
+ }
65
+ const targetIndex = path.join(distRoot, relativeIndex);
66
+ await ensureDir(path.dirname(targetIndex));
67
+ await copy(sourceIndex, targetIndex);
68
+ }
69
+ }
70
+ async function resolveWorkspaceSiteUrl(workspaceRoot) {
71
+ const fromEnv = process.env.WEBSTIR_SITE_URL?.trim();
72
+ if (fromEnv) {
73
+ return fromEnv;
74
+ }
75
+ const pkgPath = path.join(workspaceRoot, 'package.json');
76
+ const pkg = await readJson(pkgPath);
77
+ const webstir = pkg?.webstir;
78
+ if (!webstir || typeof webstir !== 'object') {
79
+ return undefined;
80
+ }
81
+ const candidate = webstir.siteUrl;
82
+ return typeof candidate === 'string' && candidate.trim() ? candidate.trim() : undefined;
83
+ }
84
+ async function applyStaticPathAliases(config, distRoot, distPagesRoot, pageIndexMap) {
85
+ if (pageIndexMap.size === 0) {
86
+ return;
87
+ }
88
+ const workspaceRoot = config.paths.workspace;
89
+ const pkgPath = path.join(workspaceRoot, 'package.json');
90
+ const pkg = await readJson(pkgPath);
91
+ const workspaceMode = pkg?.webstir?.mode;
92
+ const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg';
93
+ const moduleConfig = pkg?.webstir?.moduleManifest;
94
+ assertNoSsgRoutesInModuleConfig(moduleConfig);
95
+ const views = moduleConfig?.views ?? [];
96
+ if (views.length === 0) {
97
+ return;
98
+ }
99
+ for (const view of views) {
100
+ const renderMode = view.renderMode ?? (isSsgWorkspace ? 'ssg' : undefined);
101
+ if (renderMode !== 'ssg') {
102
+ continue;
103
+ }
104
+ const paths = getEffectiveStaticPaths(view, isSsgWorkspace);
105
+ for (const raw of paths) {
106
+ if (typeof raw !== 'string' || raw.length === 0) {
107
+ continue;
108
+ }
109
+ const normalized = normalizeStaticPath(raw);
110
+ let sourceIndex;
111
+ if (normalized === '/') {
112
+ sourceIndex = pageIndexMap.get(FOLDERS.home);
113
+ }
114
+ else {
115
+ const relativePath = normalized.replace(/^\/+/, '');
116
+ const candidate = path.join(distPagesRoot, relativePath, FILES.indexHtml);
117
+ if (await pathExists(candidate)) {
118
+ sourceIndex = candidate;
119
+ }
120
+ else {
121
+ const pageName = firstPathSegment(normalized);
122
+ if (!pageName) {
123
+ continue;
124
+ }
125
+ sourceIndex = pageIndexMap.get(pageName);
126
+ }
127
+ }
128
+ if (!sourceIndex) {
129
+ continue;
130
+ }
131
+ const targetIndex = normalized === '/'
132
+ ? path.join(distRoot, FILES.indexHtml)
133
+ : path.join(distRoot, normalized.replace(/^\/+/, ''), FILES.indexHtml);
134
+ if (path.resolve(sourceIndex) === path.resolve(targetIndex)) {
135
+ continue;
136
+ }
137
+ await ensureDir(path.dirname(targetIndex));
138
+ await copy(sourceIndex, targetIndex);
139
+ }
140
+ }
141
+ }
142
+ function normalizeStaticPath(value) {
143
+ let s = value.trim();
144
+ if (!s.startsWith('/')) {
145
+ s = `/${s}`;
146
+ }
147
+ if (s.length > 1 && s.endsWith('/')) {
148
+ s = s.slice(0, -1);
149
+ }
150
+ return s;
151
+ }
152
+ function firstPathSegment(pathname) {
153
+ const [, segment] = pathname.split('/');
154
+ if (!segment) {
155
+ return undefined;
156
+ }
157
+ return segment;
158
+ }
159
+ function getEffectiveStaticPaths(view, isSsgWorkspace) {
160
+ const explicitPaths = view.staticPaths ?? [];
161
+ if (explicitPaths.length > 0) {
162
+ return explicitPaths;
163
+ }
164
+ if (!isSsgWorkspace) {
165
+ return [];
166
+ }
167
+ const candidate = view.path ?? '';
168
+ if (!isDefaultStaticPathCandidate(candidate)) {
169
+ return [];
170
+ }
171
+ return [candidate];
172
+ }
173
+ function isDefaultStaticPathCandidate(template) {
174
+ if (typeof template !== 'string') {
175
+ return false;
176
+ }
177
+ const trimmed = template.trim();
178
+ if (!trimmed.startsWith('/')) {
179
+ return false;
180
+ }
181
+ // Avoid treating parameterized or wildcard templates as a single concrete path.
182
+ if (trimmed.includes(':') || trimmed.includes('*')) {
183
+ return false;
184
+ }
185
+ return true;
186
+ }
@@ -0,0 +1,4 @@
1
+ export interface SsgSeoOptions {
2
+ readonly siteUrl?: string;
3
+ }
4
+ export declare function runSsgSeo(distRoot: string, options?: SsgSeoOptions): Promise<void>;
@@ -0,0 +1,208 @@
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
+ export async function runSsgSeo(distRoot, options = {}) {
7
+ const pages = await discoverHtmlPages(distRoot);
8
+ await validateInternalLinks(pages, distRoot);
9
+ await writeSitemap(distRoot, pages, options.siteUrl);
10
+ await writeRobots(distRoot, options.siteUrl);
11
+ }
12
+ async function discoverHtmlPages(distRoot) {
13
+ if (!(await pathExists(distRoot))) {
14
+ return [];
15
+ }
16
+ const files = await glob('**/index.html', { cwd: distRoot, nodir: true, ignore: ['pages/**'] });
17
+ const pages = files
18
+ .map((relative) => {
19
+ const normalized = relative.split(path.sep).join('/');
20
+ const urlPath = toUrlPath(normalized);
21
+ return {
22
+ filePath: path.join(distRoot, relative),
23
+ urlPath
24
+ };
25
+ })
26
+ .filter((page) => Boolean(page.urlPath));
27
+ pages.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
28
+ return pages;
29
+ }
30
+ function toUrlPath(relativeIndex) {
31
+ if (relativeIndex === FILES.indexHtml) {
32
+ return '/';
33
+ }
34
+ if (relativeIndex.endsWith(`/${FILES.indexHtml}`)) {
35
+ return `/${relativeIndex.slice(0, -FILES.indexHtml.length)}`;
36
+ }
37
+ return '';
38
+ }
39
+ async function validateInternalLinks(pages, distRoot) {
40
+ if (pages.length === 0) {
41
+ return;
42
+ }
43
+ const knownPages = new Set(pages.map((page) => page.urlPath));
44
+ const idsByPath = await collectIdsByPath(pages);
45
+ const errors = [];
46
+ for (const page of pages) {
47
+ const html = await readFile(page.filePath);
48
+ const doc = load(html);
49
+ const anchors = doc('a[href]').toArray();
50
+ for (const element of anchors) {
51
+ const rawHref = doc(element).attr('href')?.trim() ?? '';
52
+ if (!rawHref) {
53
+ continue;
54
+ }
55
+ if (isExternalHref(rawHref)) {
56
+ continue;
57
+ }
58
+ const resolved = resolveHref(page.urlPath, rawHref);
59
+ if (!resolved) {
60
+ continue;
61
+ }
62
+ const normalizedPath = normalizePagePath(resolved.pathname, knownPages);
63
+ if (!normalizedPath) {
64
+ // Still allow links to known static assets (best-effort).
65
+ if (await targetExistsInDist(distRoot, resolved.pathname)) {
66
+ continue;
67
+ }
68
+ errors.push(`${page.filePath}: broken link '${rawHref}'`);
69
+ continue;
70
+ }
71
+ const hash = resolved.hash.startsWith('#') ? resolved.hash.slice(1) : resolved.hash;
72
+ if (!hash) {
73
+ continue;
74
+ }
75
+ const ids = idsByPath.get(normalizedPath) ?? new Set();
76
+ if (!ids.has(hash)) {
77
+ errors.push(`${page.filePath}: broken anchor '${rawHref}' (missing '#${hash}' on ${normalizedPath})`);
78
+ }
79
+ }
80
+ }
81
+ if (errors.length === 0) {
82
+ return;
83
+ }
84
+ const preview = errors.slice(0, 16).join('\n');
85
+ const suffix = errors.length > 16 ? `\n… and ${errors.length - 16} more.` : '';
86
+ throw new Error(`Broken links found in publish output:\n${preview}${suffix}`);
87
+ }
88
+ async function collectIdsByPath(pages) {
89
+ const idsByPath = new Map();
90
+ for (const page of pages) {
91
+ const html = await readFile(page.filePath);
92
+ const doc = load(html);
93
+ const ids = new Set();
94
+ doc('[id]').each((_, element) => {
95
+ const raw = doc(element).attr('id');
96
+ const value = typeof raw === 'string' ? raw.trim() : '';
97
+ if (value) {
98
+ ids.add(value);
99
+ }
100
+ });
101
+ idsByPath.set(page.urlPath, ids);
102
+ }
103
+ return idsByPath;
104
+ }
105
+ function isExternalHref(href) {
106
+ if (href.startsWith('//')) {
107
+ return true;
108
+ }
109
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
110
+ }
111
+ function resolveHref(basePath, href) {
112
+ try {
113
+ const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
114
+ return new URL(href, `http://webstir.local${base}`);
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ function normalizePagePath(pathname, knownPages) {
121
+ if (pathname === '/' || pathname === '') {
122
+ return '/';
123
+ }
124
+ if (pathname.endsWith('/index.html')) {
125
+ const asDir = pathname.slice(0, -'index.html'.length);
126
+ return knownPages.has(asDir) ? asDir : null;
127
+ }
128
+ if (pathname.endsWith('.html')) {
129
+ return knownPages.has(pathname) ? pathname : null;
130
+ }
131
+ if (pathname.endsWith('/')) {
132
+ return knownPages.has(pathname) ? pathname : null;
133
+ }
134
+ const withSlash = `${pathname}/`;
135
+ if (knownPages.has(withSlash)) {
136
+ return withSlash;
137
+ }
138
+ return knownPages.has(pathname) ? pathname : null;
139
+ }
140
+ async function targetExistsInDist(distRoot, pathname) {
141
+ if (!pathname.startsWith('/')) {
142
+ return false;
143
+ }
144
+ const relative = pathname.replace(/^\/+/, '');
145
+ if (!relative) {
146
+ return true;
147
+ }
148
+ const full = path.join(distRoot, relative);
149
+ if (await pathExists(full)) {
150
+ return true;
151
+ }
152
+ const asIndex = path.join(distRoot, relative, FILES.indexHtml);
153
+ return pathExists(asIndex);
154
+ }
155
+ async function writeSitemap(distRoot, pages, siteUrl) {
156
+ const urls = pages.map((page) => page.urlPath).filter((url) => url.startsWith('/'));
157
+ const unique = Array.from(new Set(urls)).sort((a, b) => a.localeCompare(b));
158
+ const baseUrl = normalizeSiteUrl(siteUrl);
159
+ const comment = baseUrl ? '' : '<!-- Set WEBSTIR_SITE_URL to emit absolute <loc> entries. -->\n';
160
+ const entries = unique
161
+ .map((pathname) => {
162
+ const loc = baseUrl ? new URL(pathname, baseUrl).href : pathname;
163
+ return ` <url><loc>${escapeXml(loc)}</loc></url>`;
164
+ })
165
+ .join('\n');
166
+ const xml = [
167
+ '<?xml version="1.0" encoding="UTF-8"?>',
168
+ comment + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
169
+ entries,
170
+ '</urlset>',
171
+ ''
172
+ ].join('\n');
173
+ const outputPath = path.join(distRoot, 'sitemap.xml');
174
+ await ensureDir(path.dirname(outputPath));
175
+ await writeFile(outputPath, xml);
176
+ }
177
+ async function writeRobots(distRoot, siteUrl) {
178
+ const baseUrl = normalizeSiteUrl(siteUrl);
179
+ const sitemapLine = baseUrl ? `\nSitemap: ${new URL('/sitemap.xml', baseUrl).href}` : '';
180
+ const content = `User-agent: *\nAllow: /${sitemapLine}\n`;
181
+ const outputPath = path.join(distRoot, FILES.robotsTxt);
182
+ await ensureDir(path.dirname(outputPath));
183
+ await writeFile(outputPath, content);
184
+ }
185
+ function normalizeSiteUrl(value) {
186
+ const trimmed = (value ?? '').trim();
187
+ if (!trimmed) {
188
+ return undefined;
189
+ }
190
+ try {
191
+ const url = new URL(trimmed);
192
+ url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
193
+ url.hash = '';
194
+ url.search = '';
195
+ return url.toString();
196
+ }
197
+ catch {
198
+ return undefined;
199
+ }
200
+ }
201
+ function escapeXml(value) {
202
+ return value
203
+ .replace(/&/g, '&amp;')
204
+ .replace(/</g, '&lt;')
205
+ .replace(/>/g, '&gt;')
206
+ .replace(/"/g, '&quot;')
207
+ .replace(/'/g, '&apos;');
208
+ }
@@ -0,0 +1,3 @@
1
+ import type { WorkspaceModuleConfig } from '../../config/workspaceManifest.js';
2
+ export declare function assertNoSsgRoutesInModuleConfig(moduleConfig: WorkspaceModuleConfig | undefined): void;
3
+ export declare function assertNoSsgRoutes(workspaceRoot: string): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import path from 'node:path';
2
+ import { readJson } from '../../utils/fs.js';
3
+ export function assertNoSsgRoutesInModuleConfig(moduleConfig) {
4
+ const routes = moduleConfig?.routes ?? [];
5
+ if (!Array.isArray(routes) || routes.length === 0) {
6
+ return;
7
+ }
8
+ const hasSsgRoute = routes.some((route) => {
9
+ if (!route || typeof route !== 'object') {
10
+ return false;
11
+ }
12
+ const renderMode = typeof route.renderMode === 'string' ? route.renderMode.toLowerCase() : undefined;
13
+ const hasStaticPaths = Array.isArray(route.staticPaths) && route.staticPaths.length > 0;
14
+ const hasSsgBlock = route.ssg !== undefined;
15
+ return renderMode === 'ssg' || hasStaticPaths || hasSsgBlock;
16
+ });
17
+ if (!hasSsgRoute) {
18
+ return;
19
+ }
20
+ throw new Error("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.");
21
+ }
22
+ export async function assertNoSsgRoutes(workspaceRoot) {
23
+ const pkgPath = path.join(workspaceRoot, 'package.json');
24
+ const pkg = await readJson(pkgPath);
25
+ const moduleConfig = pkg?.webstir?.moduleManifest;
26
+ assertNoSsgRoutesInModuleConfig(moduleConfig);
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { FrontendConfig } from '../../types.js';
2
+ export declare function generateSsgViewData(config: FrontendConfig): Promise<void>;