@bleedingdev/modern-js-create 3.2.0-ultramodern.120 → 3.2.0-ultramodern.121
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/README.md +35 -12
- package/dist/cjs/create-package-root.cjs +7 -9
- package/dist/cjs/index.cjs +74 -44
- package/dist/cjs/locale/en.cjs +6 -7
- package/dist/cjs/locale/zh.cjs +6 -7
- package/dist/cjs/ultramodern-workspace/add-vertical.cjs +337 -0
- package/dist/cjs/ultramodern-workspace/app-files.cjs +223 -0
- package/dist/cjs/ultramodern-workspace/contracts.cjs +836 -0
- package/dist/cjs/ultramodern-workspace/demo-components.cjs +422 -0
- package/dist/cjs/ultramodern-workspace/descriptors.cjs +222 -0
- package/dist/cjs/ultramodern-workspace/effect-api.cjs +952 -0
- package/dist/cjs/ultramodern-workspace/fs-io.cjs +191 -0
- package/dist/cjs/ultramodern-workspace/index.cjs +48 -0
- package/dist/cjs/ultramodern-workspace/locales.cjs +173 -0
- package/dist/cjs/ultramodern-workspace/module-federation.cjs +487 -0
- package/dist/cjs/ultramodern-workspace/naming.cjs +161 -0
- package/dist/cjs/ultramodern-workspace/package-json.cjs +406 -0
- package/dist/cjs/ultramodern-workspace/package-source.cjs +59 -0
- package/dist/cjs/ultramodern-workspace/policy.cjs +248 -0
- package/dist/cjs/ultramodern-workspace/public-surface.cjs +268 -0
- package/dist/cjs/ultramodern-workspace/routes.cjs +375 -0
- package/dist/cjs/ultramodern-workspace/types.cjs +61 -0
- package/dist/cjs/ultramodern-workspace/versions.cjs +153 -0
- package/dist/cjs/ultramodern-workspace/workspace-scripts.cjs +153 -0
- package/dist/cjs/ultramodern-workspace/write-workspace.cjs +175 -0
- package/dist/esm/create-package-root.js +7 -9
- package/dist/esm/index.js +72 -42
- package/dist/esm/locale/en.js +6 -7
- package/dist/esm/locale/zh.js +6 -7
- package/dist/esm/ultramodern-workspace/add-vertical.js +252 -0
- package/dist/esm/ultramodern-workspace/app-files.js +149 -0
- package/dist/esm/ultramodern-workspace/contracts.js +741 -0
- package/dist/esm/ultramodern-workspace/demo-components.js +363 -0
- package/dist/esm/ultramodern-workspace/descriptors.js +133 -0
- package/dist/esm/ultramodern-workspace/effect-api.js +854 -0
- package/dist/esm/ultramodern-workspace/fs-io.js +90 -0
- package/dist/esm/ultramodern-workspace/index.js +3 -0
- package/dist/esm/ultramodern-workspace/locales.js +122 -0
- package/dist/esm/ultramodern-workspace/module-federation.js +415 -0
- package/dist/esm/ultramodern-workspace/naming.js +71 -0
- package/dist/esm/ultramodern-workspace/package-json.js +338 -0
- package/dist/esm/ultramodern-workspace/package-source.js +21 -0
- package/dist/esm/ultramodern-workspace/policy.js +183 -0
- package/dist/esm/ultramodern-workspace/public-surface.js +183 -0
- package/dist/esm/ultramodern-workspace/routes.js +280 -0
- package/dist/esm/ultramodern-workspace/types.js +16 -0
- package/dist/esm/ultramodern-workspace/versions.js +34 -0
- package/dist/esm/ultramodern-workspace/workspace-scripts.js +91 -0
- package/dist/esm/ultramodern-workspace/write-workspace.js +121 -0
- package/dist/esm-node/create-package-root.js +7 -9
- package/dist/esm-node/index.js +72 -42
- package/dist/esm-node/locale/en.js +6 -7
- package/dist/esm-node/locale/zh.js +6 -7
- package/dist/esm-node/ultramodern-workspace/add-vertical.js +253 -0
- package/dist/esm-node/ultramodern-workspace/app-files.js +150 -0
- package/dist/esm-node/ultramodern-workspace/contracts.js +742 -0
- package/dist/esm-node/ultramodern-workspace/demo-components.js +364 -0
- package/dist/esm-node/ultramodern-workspace/descriptors.js +134 -0
- package/dist/esm-node/ultramodern-workspace/effect-api.js +855 -0
- package/dist/esm-node/ultramodern-workspace/fs-io.js +91 -0
- package/dist/esm-node/ultramodern-workspace/index.js +4 -0
- package/dist/esm-node/ultramodern-workspace/locales.js +123 -0
- package/dist/esm-node/ultramodern-workspace/module-federation.js +416 -0
- package/dist/esm-node/ultramodern-workspace/naming.js +72 -0
- package/dist/esm-node/ultramodern-workspace/package-json.js +339 -0
- package/dist/esm-node/ultramodern-workspace/package-source.js +22 -0
- package/dist/esm-node/ultramodern-workspace/policy.js +184 -0
- package/dist/esm-node/ultramodern-workspace/public-surface.js +184 -0
- package/dist/esm-node/ultramodern-workspace/routes.js +281 -0
- package/dist/esm-node/ultramodern-workspace/types.js +17 -0
- package/dist/esm-node/ultramodern-workspace/versions.js +35 -0
- package/dist/esm-node/ultramodern-workspace/workspace-scripts.js +92 -0
- package/dist/esm-node/ultramodern-workspace/write-workspace.js +122 -0
- package/dist/types/locale/en.d.ts +4 -5
- package/dist/types/locale/index.d.ts +8 -10
- package/dist/types/locale/zh.d.ts +4 -5
- package/dist/types/ultramodern-workspace/add-vertical.d.ts +19 -0
- package/dist/types/ultramodern-workspace/app-files.d.ts +14 -0
- package/dist/types/ultramodern-workspace/contracts.d.ts +21 -0
- package/dist/types/ultramodern-workspace/demo-components.d.ts +9 -0
- package/dist/types/ultramodern-workspace/descriptors.d.ts +39 -0
- package/dist/types/ultramodern-workspace/effect-api.d.ts +73 -0
- package/dist/types/ultramodern-workspace/fs-io.d.ts +18 -0
- package/dist/types/ultramodern-workspace/index.d.ts +4 -0
- package/dist/types/ultramodern-workspace/locales.d.ts +183 -0
- package/dist/types/ultramodern-workspace/module-federation.d.ts +16 -0
- package/dist/types/ultramodern-workspace/naming.d.ts +16 -0
- package/dist/types/ultramodern-workspace/package-json.d.ts +12 -0
- package/dist/types/ultramodern-workspace/package-source.d.ts +2 -0
- package/dist/types/ultramodern-workspace/policy.d.ts +60 -0
- package/dist/types/ultramodern-workspace/public-surface.d.ts +37 -0
- package/dist/types/ultramodern-workspace/routes.d.ts +25 -0
- package/dist/types/ultramodern-workspace/types.d.ts +95 -0
- package/dist/types/ultramodern-workspace/versions.d.ts +38 -0
- package/dist/types/ultramodern-workspace/workspace-scripts.d.ts +10 -0
- package/dist/types/ultramodern-workspace/write-workspace.d.ts +4 -0
- package/package.json +4 -3
- package/template-workspace/.github/workflows/ultramodern-workspace-gates.yml.handlebars +1 -4
- package/template-workspace/.mise.toml.handlebars +1 -0
- package/template-workspace/{AGENTS.md → AGENTS.md.handlebars} +12 -7
- package/template-workspace/README.md.handlebars +40 -24
- package/template-workspace/{pnpm-workspace.yaml → pnpm-workspace.yaml.handlebars} +2 -2
- package/template-workspace/scripts/bootstrap-agent-skills.mjs +31 -51
- package/templates/app/shell-frame.tsx +49 -0
- package/templates/app/ultramodern-route-head.tsx.handlebars +142 -0
- package/templates/packages/shared-contracts-index.ts +466 -0
- package/templates/workspace-scripts/assert-mf-types.mjs.handlebars +69 -0
- package/templates/workspace-scripts/check-ultramodern-i18n-boundaries.mjs +9 -0
- package/templates/workspace-scripts/generate-public-surface-assets.mjs +529 -0
- package/templates/workspace-scripts/proof-cloudflare-version.mjs +125 -0
- package/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs +851 -0
- package/templates/workspace-scripts/ultramodern-performance-readiness.config.mjs +7 -0
- package/templates/workspace-scripts/ultramodern-performance-readiness.mjs +223 -0
- package/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars +593 -0
- package/dist/cjs/ultramodern-workspace.cjs +0 -6797
- package/dist/esm/ultramodern-workspace.js +0 -6738
- package/dist/esm-node/ultramodern-workspace.js +0 -6739
- package/dist/types/ultramodern-workspace.d.ts +0 -29
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const workspaceRoot = path.resolve(
|
|
7
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
8
|
+
'..',
|
|
9
|
+
);
|
|
10
|
+
const contractPath = path.join(
|
|
11
|
+
workspaceRoot,
|
|
12
|
+
'.modernjs/ultramodern-generated-contract.json',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
function readJson(filePath) {
|
|
16
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const parsed = {
|
|
21
|
+
appId: undefined,
|
|
22
|
+
target: 'dist',
|
|
23
|
+
requirePublicOrigin: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
27
|
+
const arg = argv[index];
|
|
28
|
+
if (arg === '--app') {
|
|
29
|
+
parsed.appId = argv[index + 1];
|
|
30
|
+
index += 1;
|
|
31
|
+
} else if (arg === '--target') {
|
|
32
|
+
parsed.target = argv[index + 1];
|
|
33
|
+
index += 1;
|
|
34
|
+
} else if (arg === '--require-public-origin') {
|
|
35
|
+
parsed.requirePublicOrigin = true;
|
|
36
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
37
|
+
parsed.help = true;
|
|
38
|
+
} else {
|
|
39
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!parsed.appId && !parsed.help) {
|
|
44
|
+
throw new Error('Missing required --app argument');
|
|
45
|
+
}
|
|
46
|
+
if (!['dist', 'cloudflare'].includes(parsed.target)) {
|
|
47
|
+
throw new Error(`Unsupported public surface target: ${parsed.target}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printHelp() {
|
|
54
|
+
process.stdout.write(`Usage:
|
|
55
|
+
node scripts/generate-public-surface-assets.mjs --app shell-super-app [--target dist|cloudflare] [--require-public-origin]
|
|
56
|
+
|
|
57
|
+
Set each app's production URL using the contract env key, for example:
|
|
58
|
+
ULTRAMODERN_PUBLIC_URL_SHELL_SUPER_APP=https://example.com
|
|
59
|
+
|
|
60
|
+
Dynamic public routes can opt into sitemap expansion by adding a route-owned
|
|
61
|
+
route.sitemap.mjs provider beside route metadata, or by adding an
|
|
62
|
+
explicit provider to routes.publicSurface.contentSources. Providers should export
|
|
63
|
+
an entries array, entries() function, or default entries/loader returning
|
|
64
|
+
UltramodernPublicSitemapEntry[].
|
|
65
|
+
`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeOrigin(value) {
|
|
69
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const url = new URL(value);
|
|
73
|
+
return url.origin;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveOrigin(app, requirePublicOrigin) {
|
|
77
|
+
const cloudflare = app.deploy?.cloudflare ?? {};
|
|
78
|
+
const publicUrlEnv = cloudflare.publicUrlEnv;
|
|
79
|
+
const fromAppEnv =
|
|
80
|
+
typeof publicUrlEnv === 'string' ? normalizeOrigin(process.env[publicUrlEnv]) : undefined;
|
|
81
|
+
const fromGlobalEnv = normalizeOrigin(process.env.MODERN_PUBLIC_SITE_URL);
|
|
82
|
+
const workersDevSubdomain = process.env.ULTRAMODERN_CLOUDFLARE_WORKERS_DEV_SUBDOMAIN;
|
|
83
|
+
const fromWorkersDev =
|
|
84
|
+
typeof workersDevSubdomain === 'string' && workersDevSubdomain.trim() !== ''
|
|
85
|
+
? normalizeOrigin(`https://${cloudflare.workerName}.${workersDevSubdomain}.workers.dev`)
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
88
|
+
// SEO output (sitemap <loc>, robots Sitemap:) uses the site-wide origin
|
|
89
|
+
// first; the per-app deployment URL is only a fallback.
|
|
90
|
+
const configuredOrigin = fromGlobalEnv ?? fromAppEnv ?? fromWorkersDev;
|
|
91
|
+
if (configuredOrigin) {
|
|
92
|
+
return configuredOrigin;
|
|
93
|
+
}
|
|
94
|
+
if (requirePublicOrigin) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`${app.id} has public routes but no production public URL. Set ${publicUrlEnv ?? 'ULTRAMODERN_PUBLIC_URL_<APP>'} or MODERN_PUBLIC_SITE_URL.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensureOutputDir(app, target) {
|
|
103
|
+
const relativeDir =
|
|
104
|
+
target === 'cloudflare'
|
|
105
|
+
? app.routes?.publicSurface?.cloudflareOutputRoot
|
|
106
|
+
: app.routes?.publicSurface?.outputRoot;
|
|
107
|
+
if (typeof relativeDir !== 'string') {
|
|
108
|
+
throw new Error(`${app.id} public surface contract is missing outputRoot for ${target}`);
|
|
109
|
+
}
|
|
110
|
+
const outputDir = path.resolve(workspaceRoot, app.path, relativeDir);
|
|
111
|
+
const appRoot = path.resolve(workspaceRoot, app.path);
|
|
112
|
+
if (!outputDir.startsWith(appRoot + path.sep)) {
|
|
113
|
+
throw new Error(`${app.id} public surface output escaped the app directory`);
|
|
114
|
+
}
|
|
115
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
116
|
+
return outputDir;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveAppRelativePath(app, relativePath) {
|
|
120
|
+
if (
|
|
121
|
+
typeof relativePath !== 'string' ||
|
|
122
|
+
relativePath.trim() === '' ||
|
|
123
|
+
path.isAbsolute(relativePath) ||
|
|
124
|
+
relativePath.split(/[\\/]+/).includes('..')
|
|
125
|
+
) {
|
|
126
|
+
throw new Error(app.id + ' public content source has an unsafe module path');
|
|
127
|
+
}
|
|
128
|
+
const appRoot = path.resolve(workspaceRoot, app.path);
|
|
129
|
+
const resolved = path.resolve(appRoot, relativePath);
|
|
130
|
+
if (resolved !== appRoot && !resolved.startsWith(appRoot + path.sep)) {
|
|
131
|
+
throw new Error(app.id + ' public content source escaped the app directory');
|
|
132
|
+
}
|
|
133
|
+
return resolved;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizePublicPath(pathname) {
|
|
137
|
+
if (typeof pathname !== 'string') {
|
|
138
|
+
throw new Error('Public route path must be a string');
|
|
139
|
+
}
|
|
140
|
+
const normalised = pathname
|
|
141
|
+
.trim()
|
|
142
|
+
.replaceAll(/\/+/gu, '/')
|
|
143
|
+
.replace(/\/+$/u, '');
|
|
144
|
+
return normalised.length > 0 && normalised.startsWith('/')
|
|
145
|
+
? normalised
|
|
146
|
+
: '/' + normalised;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function createLocalisedPublicPath(pathname, language) {
|
|
150
|
+
const publicPath = normalizePublicPath(pathname);
|
|
151
|
+
return publicPath === '/' ? '/' + language : '/' + language + publicPath;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function splitPublicPathSegments(pathname) {
|
|
155
|
+
return normalizePublicPath(pathname).split('/').filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function routePathParamName(segment) {
|
|
159
|
+
if (segment.startsWith(':')) {
|
|
160
|
+
return segment.slice(1).replace(/[?*+]$/u, '');
|
|
161
|
+
}
|
|
162
|
+
if (segment.startsWith('[') && segment.endsWith(']')) {
|
|
163
|
+
return segment.slice(1, -1).replace(/^\.\.\./u, '').replace(/\$$/u, '');
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function routeSegmentToDirectory(segment) {
|
|
169
|
+
const paramName = routePathParamName(segment);
|
|
170
|
+
if (paramName && segment.startsWith(':')) {
|
|
171
|
+
return segment.endsWith('?') ? '[' + paramName + '$]' : '[' + paramName + ']';
|
|
172
|
+
}
|
|
173
|
+
return segment;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function assertParamValue(routeId, language, paramName, value) {
|
|
177
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
178
|
+
throw new Error(routeId + ' ' + language + ' sitemap param ' + paramName + ' must be a string, number, or boolean');
|
|
179
|
+
}
|
|
180
|
+
const text = String(value).trim();
|
|
181
|
+
if (text === '' || text.includes('/')) {
|
|
182
|
+
throw new Error(routeId + ' ' + language + ' sitemap param ' + paramName + ' must be a non-empty path segment');
|
|
183
|
+
}
|
|
184
|
+
return encodeURIComponent(text);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function expandPublicPathPattern(routeId, language, pattern, params) {
|
|
188
|
+
const segments = splitPublicPathSegments(pattern);
|
|
189
|
+
if (segments.length === 0) {
|
|
190
|
+
return '/';
|
|
191
|
+
}
|
|
192
|
+
const expanded = segments.map(segment => {
|
|
193
|
+
const paramName = routePathParamName(segment);
|
|
194
|
+
if (!paramName) {
|
|
195
|
+
if (segment.includes('*')) {
|
|
196
|
+
throw new Error(routeId + ' ' + language + ' sitemap expansion does not support wildcard path segment ' + segment);
|
|
197
|
+
}
|
|
198
|
+
return segment;
|
|
199
|
+
}
|
|
200
|
+
if (!Object.prototype.hasOwnProperty.call(params, paramName)) {
|
|
201
|
+
throw new Error(routeId + ' ' + language + ' sitemap entry is missing param ' + paramName);
|
|
202
|
+
}
|
|
203
|
+
return assertParamValue(routeId, language, paramName, params[paramName]);
|
|
204
|
+
});
|
|
205
|
+
return '/' + expanded.join('/');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function assertPlainObject(value, label) {
|
|
209
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
210
|
+
throw new Error(label + ' must be an object');
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeSitemapFields(routeId, entry) {
|
|
216
|
+
const normalized = {};
|
|
217
|
+
if (entry.lastModified !== undefined) {
|
|
218
|
+
const lastModified = String(entry.lastModified).trim();
|
|
219
|
+
if (lastModified === '' || Number.isNaN(Date.parse(lastModified))) {
|
|
220
|
+
throw new Error(routeId + ' sitemap entry has invalid lastModified');
|
|
221
|
+
}
|
|
222
|
+
normalized.lastModified = lastModified;
|
|
223
|
+
}
|
|
224
|
+
if (entry.changeFrequency !== undefined) {
|
|
225
|
+
const allowed = new Set(['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']);
|
|
226
|
+
if (!allowed.has(entry.changeFrequency)) {
|
|
227
|
+
throw new Error(routeId + ' sitemap entry has invalid changeFrequency');
|
|
228
|
+
}
|
|
229
|
+
normalized.changeFrequency = entry.changeFrequency;
|
|
230
|
+
}
|
|
231
|
+
if (entry.priority !== undefined) {
|
|
232
|
+
if (typeof entry.priority !== 'number' || entry.priority < 0 || entry.priority > 1) {
|
|
233
|
+
throw new Error(routeId + ' sitemap entry priority must be a number between 0 and 1');
|
|
234
|
+
}
|
|
235
|
+
normalized.priority = entry.priority;
|
|
236
|
+
}
|
|
237
|
+
return normalized;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function routePathToProviderDirectory(routePath) {
|
|
241
|
+
const segments = splitPublicPathSegments(routePath);
|
|
242
|
+
if (segments.length === 0) {
|
|
243
|
+
return 'src/routes/[lang]';
|
|
244
|
+
}
|
|
245
|
+
return path.posix.join(
|
|
246
|
+
'src/routes/[lang]',
|
|
247
|
+
...segments.map(routeSegmentToDirectory),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function createDiscoveredContentSources(app, publicSurface) {
|
|
252
|
+
const explicitRouteIds = new Set(
|
|
253
|
+
(publicSurface.contentSources ?? []).map(source => source.routeId),
|
|
254
|
+
);
|
|
255
|
+
const discovered = [];
|
|
256
|
+
for (const route of publicSurface.publicRoutes ?? []) {
|
|
257
|
+
if (
|
|
258
|
+
explicitRouteIds.has(route.id) ||
|
|
259
|
+
!Object.values(route.localisedPaths ?? {}).some(routePath =>
|
|
260
|
+
/(?:^|\/):[^/]+|\[[^\]]+\]/u.test(routePath),
|
|
261
|
+
)
|
|
262
|
+
) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const providerModule = path.posix.join(
|
|
266
|
+
routePathToProviderDirectory(route.canonicalPath),
|
|
267
|
+
'route.sitemap.mjs',
|
|
268
|
+
);
|
|
269
|
+
if (fs.existsSync(resolveAppRelativePath(app, providerModule))) {
|
|
270
|
+
discovered.push({
|
|
271
|
+
entryExport: 'default-or-entries',
|
|
272
|
+
module: providerModule,
|
|
273
|
+
routeId: route.id,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return discovered;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function resolveContentSources(app, publicSurface) {
|
|
281
|
+
return [
|
|
282
|
+
...(publicSurface.contentSources ?? []),
|
|
283
|
+
...createDiscoveredContentSources(app, publicSurface),
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function loadContentSourceEntries(app, contentSource, languages) {
|
|
288
|
+
if (typeof contentSource?.routeId !== 'string' || contentSource.routeId.trim() === '') {
|
|
289
|
+
throw new Error(app.id + ' public content source is missing routeId');
|
|
290
|
+
}
|
|
291
|
+
const modulePath = resolveAppRelativePath(app, contentSource.module);
|
|
292
|
+
const moduleExports = await import(pathToFileURL(modulePath).href);
|
|
293
|
+
const exported = moduleExports.default ?? moduleExports.entries;
|
|
294
|
+
const rawEntries =
|
|
295
|
+
typeof exported === 'function'
|
|
296
|
+
? await exported({
|
|
297
|
+
appId: app.id,
|
|
298
|
+
languages,
|
|
299
|
+
routeId: contentSource.routeId,
|
|
300
|
+
})
|
|
301
|
+
: exported;
|
|
302
|
+
if (!Array.isArray(rawEntries)) {
|
|
303
|
+
throw new Error(app.id + ' public content source for ' + contentSource.routeId + ' must export an entries array or loader');
|
|
304
|
+
}
|
|
305
|
+
return rawEntries;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function expandContentSources(app, publicSurface, languages) {
|
|
309
|
+
const routesById = new Map(
|
|
310
|
+
(publicSurface.publicRoutes ?? []).map(route => [route.id, route]),
|
|
311
|
+
);
|
|
312
|
+
const expanded = [];
|
|
313
|
+
for (const contentSource of resolveContentSources(app, publicSurface)) {
|
|
314
|
+
const route = routesById.get(contentSource.routeId);
|
|
315
|
+
if (!route) {
|
|
316
|
+
throw new Error(app.id + ' public content source references unknown route ' + contentSource.routeId);
|
|
317
|
+
}
|
|
318
|
+
const rawEntries = await loadContentSourceEntries(app, contentSource, languages);
|
|
319
|
+
for (const rawEntry of rawEntries) {
|
|
320
|
+
const entry = assertPlainObject(rawEntry, route.id + ' sitemap entry');
|
|
321
|
+
if (entry.draft === true || entry.indexable === false) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const baseParams = assertPlainObject(entry.params, route.id + ' sitemap entry params');
|
|
325
|
+
const localeParams = entry.localeParams === undefined
|
|
326
|
+
? {}
|
|
327
|
+
: assertPlainObject(entry.localeParams, route.id + ' sitemap entry localeParams');
|
|
328
|
+
const localeUrlPaths = {};
|
|
329
|
+
for (const language of languages) {
|
|
330
|
+
const params = {
|
|
331
|
+
...baseParams,
|
|
332
|
+
...(localeParams[language] ?? {}),
|
|
333
|
+
};
|
|
334
|
+
localeUrlPaths[language] = createLocalisedPublicPath(
|
|
335
|
+
expandPublicPathPattern(route.id, language, route.localisedPaths[language], params),
|
|
336
|
+
language,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
expanded.push({
|
|
340
|
+
...route,
|
|
341
|
+
...normalizeSitemapFields(route.id, entry),
|
|
342
|
+
canonicalUrlPath: localeUrlPaths.en,
|
|
343
|
+
localeUrlPaths,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return expanded;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function mergeRouteEntries(routeEntries, expandedRouteEntries, languages) {
|
|
351
|
+
const byKey = new Map();
|
|
352
|
+
const urlPathOwners = new Map();
|
|
353
|
+
for (const route of [...routeEntries, ...expandedRouteEntries]) {
|
|
354
|
+
const key = route.id + ':' + route.canonicalUrlPath;
|
|
355
|
+
if (byKey.has(key)) {
|
|
356
|
+
throw new Error('Duplicate public sitemap route entry ' + key);
|
|
357
|
+
}
|
|
358
|
+
for (const language of languages) {
|
|
359
|
+
const urlPath = route.localeUrlPaths?.[language];
|
|
360
|
+
if (typeof urlPath !== 'string') {
|
|
361
|
+
throw new Error(route.id + ' public route entry is missing ' + language + ' locale URL path');
|
|
362
|
+
}
|
|
363
|
+
const existingOwner = urlPathOwners.get(urlPath);
|
|
364
|
+
if (existingOwner && existingOwner !== route.id) {
|
|
365
|
+
throw new Error('Duplicate public sitemap URL path ' + urlPath + ' from ' + existingOwner + ' and ' + route.id);
|
|
366
|
+
}
|
|
367
|
+
urlPathOwners.set(urlPath, route.id);
|
|
368
|
+
}
|
|
369
|
+
byKey.set(key, route);
|
|
370
|
+
}
|
|
371
|
+
return Array.from(byKey.values()).sort(
|
|
372
|
+
(left, right) =>
|
|
373
|
+
left.canonicalUrlPath.localeCompare(right.canonicalUrlPath) ||
|
|
374
|
+
left.id.localeCompare(right.id),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function uniqueSorted(values) {
|
|
379
|
+
return Array.from(new Set(values)).sort((left, right) =>
|
|
380
|
+
left.localeCompare(right),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function createConcreteUrlPaths(routeEntries, languages) {
|
|
385
|
+
return uniqueSorted(
|
|
386
|
+
routeEntries.flatMap(route => languages.map(language => route.localeUrlPaths[language])),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function escapeXmlText(value) {
|
|
391
|
+
return value
|
|
392
|
+
.replaceAll('&', '&')
|
|
393
|
+
.replaceAll('<', '<')
|
|
394
|
+
.replaceAll('>', '>');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function escapeXmlAttribute(value) {
|
|
398
|
+
return escapeXmlText(value).replaceAll('"', '"');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function renderRobotsTxt(urlPaths, sitemapUrl) {
|
|
402
|
+
const lines = ['User-agent: *'];
|
|
403
|
+
if (urlPaths.length === 0) {
|
|
404
|
+
lines.push('Disallow: /');
|
|
405
|
+
} else {
|
|
406
|
+
for (const urlPath of urlPaths) {
|
|
407
|
+
lines.push(`Allow: ${urlPath}$`);
|
|
408
|
+
}
|
|
409
|
+
lines.push('Disallow: /');
|
|
410
|
+
if (sitemapUrl) {
|
|
411
|
+
lines.push(`Sitemap: ${sitemapUrl}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return `${lines.join('\n')}\n`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function renderSitemapXml(origin, routeEntries, languages) {
|
|
418
|
+
const lines = [
|
|
419
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
420
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
for (const route of routeEntries) {
|
|
424
|
+
for (const language of languages) {
|
|
425
|
+
lines.push(' <url>');
|
|
426
|
+
lines.push(` <loc>${escapeXmlText(`${origin}${route.localeUrlPaths[language]}`)}</loc>`);
|
|
427
|
+
for (const alternateLanguage of languages) {
|
|
428
|
+
lines.push(
|
|
429
|
+
` <xhtml:link rel="alternate" hreflang="${alternateLanguage}" href="${escapeXmlAttribute(
|
|
430
|
+
`${origin}${route.localeUrlPaths[alternateLanguage]}`,
|
|
431
|
+
)}" />`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
lines.push(
|
|
435
|
+
` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXmlAttribute(
|
|
436
|
+
`${origin}${route.localeUrlPaths.en}`,
|
|
437
|
+
)}" />`,
|
|
438
|
+
);
|
|
439
|
+
if (route.lastModified) {
|
|
440
|
+
lines.push(` <lastmod>${escapeXmlText(route.lastModified)}</lastmod>`);
|
|
441
|
+
}
|
|
442
|
+
if (route.changeFrequency) {
|
|
443
|
+
lines.push(` <changefreq>${escapeXmlText(route.changeFrequency)}</changefreq>`);
|
|
444
|
+
}
|
|
445
|
+
if (route.priority !== undefined) {
|
|
446
|
+
lines.push(` <priority>${route.priority.toFixed(1).replace(/\.0$/u, '')}</priority>`);
|
|
447
|
+
}
|
|
448
|
+
lines.push(' </url>');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
lines.push('</urlset>');
|
|
453
|
+
return `${lines.join('\n')}\n`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderWebManifest(app, urlPaths) {
|
|
457
|
+
const startUrl = urlPaths[0];
|
|
458
|
+
const manifest = {
|
|
459
|
+
background_color: '#ffffff',
|
|
460
|
+
categories: ['business', 'productivity'],
|
|
461
|
+
display: 'standalone',
|
|
462
|
+
icons: [],
|
|
463
|
+
lang: 'en',
|
|
464
|
+
name: app.marker?.appId ?? app.id,
|
|
465
|
+
short_name: app.marker?.appId ?? app.id,
|
|
466
|
+
theme_color: '#133225',
|
|
467
|
+
...(startUrl ? { scope: '/', start_url: startUrl } : {}),
|
|
468
|
+
};
|
|
469
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function removeIfExists(outputDir, fileName) {
|
|
473
|
+
fs.rmSync(path.join(outputDir, fileName), { force: true });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function writeText(outputDir, fileName, content) {
|
|
477
|
+
fs.writeFileSync(path.join(outputDir, fileName), content);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function generatePublicSurfaceAssets(app, target, requirePublicOrigin) {
|
|
481
|
+
const publicSurface = app.routes?.publicSurface ?? {};
|
|
482
|
+
const languages = publicSurface.languages ?? ['en', 'cs'];
|
|
483
|
+
const outputDir = ensureOutputDir(app, target);
|
|
484
|
+
const shouldRequirePublicOrigin =
|
|
485
|
+
requirePublicOrigin ||
|
|
486
|
+
process.env.ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS === 'true';
|
|
487
|
+
const routeEntries = mergeRouteEntries(
|
|
488
|
+
publicSurface.routeEntries ?? [],
|
|
489
|
+
await expandContentSources(app, publicSurface, languages),
|
|
490
|
+
languages,
|
|
491
|
+
);
|
|
492
|
+
const urlPaths = createConcreteUrlPaths(routeEntries, languages);
|
|
493
|
+
|
|
494
|
+
if (routeEntries.length === 0) {
|
|
495
|
+
writeText(outputDir, 'robots.txt', renderRobotsTxt([], undefined));
|
|
496
|
+
removeIfExists(outputDir, 'sitemap.xml');
|
|
497
|
+
removeIfExists(outputDir, 'site.webmanifest');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const origin = resolveOrigin(app, shouldRequirePublicOrigin);
|
|
502
|
+
if (!origin) {
|
|
503
|
+
writeText(outputDir, 'robots.txt', renderRobotsTxt([], undefined));
|
|
504
|
+
removeIfExists(outputDir, 'sitemap.xml');
|
|
505
|
+
removeIfExists(outputDir, 'site.webmanifest');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
writeText(outputDir, 'sitemap.xml', renderSitemapXml(origin, routeEntries, languages));
|
|
510
|
+
writeText(outputDir, 'site.webmanifest', renderWebManifest(app, urlPaths));
|
|
511
|
+
writeText(outputDir, 'robots.txt', renderRobotsTxt(urlPaths, `${origin}/sitemap.xml`));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const args = parseArgs(process.argv.slice(2));
|
|
516
|
+
if (args.help) {
|
|
517
|
+
printHelp();
|
|
518
|
+
process.exit(0);
|
|
519
|
+
}
|
|
520
|
+
const contract = readJson(contractPath);
|
|
521
|
+
const app = contract.apps?.find(candidate => candidate.id === args.appId);
|
|
522
|
+
if (!app) {
|
|
523
|
+
throw new Error(`Unknown app in generated contract: ${args.appId}`);
|
|
524
|
+
}
|
|
525
|
+
await generatePublicSurfaceAssets(app, args.target, args.requirePublicOrigin);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
process.stderr.write(`[public-surface] ${error.message}\n`);
|
|
528
|
+
process.exitCode = 1;
|
|
529
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { validateApp } from './ultramodern-cloudflare-proof.mjs';
|
|
6
|
+
|
|
7
|
+
const workspaceRoot = path.resolve(
|
|
8
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
9
|
+
'..',
|
|
10
|
+
);
|
|
11
|
+
const contractPath = path.join(
|
|
12
|
+
workspaceRoot,
|
|
13
|
+
'.modernjs/ultramodern-generated-contract.json',
|
|
14
|
+
);
|
|
15
|
+
const defaultOut = path.join(
|
|
16
|
+
workspaceRoot,
|
|
17
|
+
'.codex/reports/cloudflare-version-proof/public-url-proof.json',
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
function readJson(filePath) {
|
|
21
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const parsed = {
|
|
26
|
+
appId: undefined,
|
|
27
|
+
out: defaultOut,
|
|
28
|
+
requirePublicUrls: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
32
|
+
const arg = argv[index];
|
|
33
|
+
if (arg === '--app') {
|
|
34
|
+
parsed.appId = argv[index + 1];
|
|
35
|
+
index += 1;
|
|
36
|
+
} else if (arg === '--out') {
|
|
37
|
+
parsed.out = argv[index + 1];
|
|
38
|
+
index += 1;
|
|
39
|
+
} else if (arg === '--require-public-urls') {
|
|
40
|
+
parsed.requirePublicUrls = true;
|
|
41
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
42
|
+
parsed.help = true;
|
|
43
|
+
} else {
|
|
44
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printHelp() {
|
|
52
|
+
process.stdout.write(`Usage:
|
|
53
|
+
node scripts/proof-cloudflare-version.mjs [--app workspace] [--out evidence.json] [--require-public-urls]
|
|
54
|
+
|
|
55
|
+
Set each app's public URL using the contract env key, for example:
|
|
56
|
+
ULTRAMODERN_PUBLIC_URL_WORKSPACE=https://workspace.example.workers.dev
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assert(condition, message) {
|
|
61
|
+
if (!condition) {
|
|
62
|
+
throw new Error(message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main(argv = process.argv.slice(2)) {
|
|
67
|
+
const args = parseArgs(argv);
|
|
68
|
+
if (args.help) {
|
|
69
|
+
printHelp();
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const contract = readJson(contractPath);
|
|
74
|
+
const apps = args.appId
|
|
75
|
+
? contract.apps.filter(app => app.id === args.appId)
|
|
76
|
+
: contract.apps;
|
|
77
|
+
assert(apps.length > 0, `No generated app matched ${args.appId}`);
|
|
78
|
+
|
|
79
|
+
const results = [];
|
|
80
|
+
const skipped = [];
|
|
81
|
+
for (const app of apps) {
|
|
82
|
+
const publicUrlEnv = app.deploy?.cloudflare?.publicUrlEnv;
|
|
83
|
+
const publicUrl = publicUrlEnv && process.env[publicUrlEnv];
|
|
84
|
+
if (!publicUrl) {
|
|
85
|
+
const skippedEntry = {
|
|
86
|
+
appId: app.id,
|
|
87
|
+
status: args.requirePublicUrls ? 'fail' : 'skipped',
|
|
88
|
+
publicUrlEnv,
|
|
89
|
+
reason: 'public URL environment variable is not set',
|
|
90
|
+
};
|
|
91
|
+
skipped.push(skippedEntry);
|
|
92
|
+
if (args.requirePublicUrls) {
|
|
93
|
+
throw new Error(`${app.id} requires ${publicUrlEnv}`);
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
results.push(await validateApp(app, publicUrl));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const report = {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
generatedAt: new Date().toISOString(),
|
|
103
|
+
status: results.length > 0 ? 'pass' : 'skipped',
|
|
104
|
+
contractPath,
|
|
105
|
+
results,
|
|
106
|
+
skipped,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
fs.mkdirSync(path.dirname(args.out), { recursive: true });
|
|
110
|
+
fs.writeFileSync(args.out, `${JSON.stringify(report, null, 2)}\n`);
|
|
111
|
+
process.stdout.write(
|
|
112
|
+
`[cloudflare-version-proof] ${report.status}: ${args.out}\n`,
|
|
113
|
+
);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main().then(
|
|
118
|
+
exitCode => {
|
|
119
|
+
process.exitCode = exitCode;
|
|
120
|
+
},
|
|
121
|
+
error => {
|
|
122
|
+
process.stderr.write(`[cloudflare-version-proof] ${error.message}\n`);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
},
|
|
125
|
+
);
|