@ultimat3/cli 29.4.0 → 30.1.0
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/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +11 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +9 -0
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +41 -81
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +7 -1
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1314 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
package/src/site-seo.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `robots.txt` and `
|
|
1
|
+
// `robots.txt`, `sitemap.xml` and `llms.txt`, off the route table: ONE answer for the static export
|
|
2
2
|
// (`apps/web/prerender.ts` writes it into the artifact) and the running web role (`seo-routes.ts`
|
|
3
3
|
// serves it), so a crawler reads the same two files from a CDN and from a container.
|
|
4
4
|
|
|
@@ -6,12 +6,15 @@ import type { Environment, SeoRobotsGroup, SeoSitemapConfig } from '@ultimat3/co
|
|
|
6
6
|
import { UltimateError } from '@ultimat3/core';
|
|
7
7
|
import { localizedPath, unlocalizedPath } from '@ultimat3/i18n';
|
|
8
8
|
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
9
|
-
import type { RouteEntry } from '@ultimat3/render';
|
|
9
|
+
import type { PrerenderEntry, RouteEntry } from '@ultimat3/render';
|
|
10
10
|
import { routeEntries } from '@ultimat3/render';
|
|
11
11
|
import { enumeratePrerenderEntries, fillPath } from '@ultimat3/render/server';
|
|
12
12
|
import type { RouteRecord, SitemapFile, SitemapPage } from '@ultimat3/seo';
|
|
13
13
|
import { buildRobots, buildSitemap, isDynamic } from '@ultimat3/seo';
|
|
14
|
+
import type { RouteEntries } from './prerender-entries';
|
|
14
15
|
import { readSiteMeta } from './seo-meta';
|
|
16
|
+
import { siteLlms } from './site-llms';
|
|
17
|
+
import type { LlmsPages } from './site-llms-pages';
|
|
15
18
|
import { lastmodOf } from './sitemap-lastmod';
|
|
16
19
|
|
|
17
20
|
export const ROBOTS_PATH = '/robots.txt';
|
|
@@ -47,6 +50,20 @@ export interface SiteSeoOptions {
|
|
|
47
50
|
readonly sitemap?: SiteSeoSitemap | undefined;
|
|
48
51
|
/** The app root the route files are relative to — read for `lastmod: 'git' | 'mtime'`. */
|
|
49
52
|
readonly root?: string | undefined;
|
|
53
|
+
/** `seo.llms` from `app.config.ts`. Omitted: on, like the sitemap. */
|
|
54
|
+
readonly llms?: boolean | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* What the static export's `prerender()` calls already answered, by declared route path
|
|
57
|
+
* (`PrerenderReport.entries`). A route listed here is never asked again: the build enumerated
|
|
58
|
+
* it inside its API scope, which is gone by the time the sitemap is written, so a `prerender()`
|
|
59
|
+
* reading the app's own query would fail the export (`X_CLIENT_TRANSPORT_FAILED`).
|
|
60
|
+
*/
|
|
61
|
+
readonly entries?: RouteEntries | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* `llms.txt`'s page records the static export read inside its API scope
|
|
64
|
+
* (`PrerenderReport.llmsPages`). A route listed here never runs its `load` again.
|
|
65
|
+
*/
|
|
66
|
+
readonly llmsPages?: LlmsPages | undefined;
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
/**
|
|
@@ -72,6 +89,8 @@ export class SitemapExtraInvalidError extends UltimateError {
|
|
|
72
89
|
|
|
73
90
|
export interface SiteSeo {
|
|
74
91
|
readonly robots: string;
|
|
92
|
+
/** `/llms.txt`: empty outside production and under `seo.llms: false`. */
|
|
93
|
+
readonly llms: string;
|
|
75
94
|
/** Every sitemap file: `/sitemap.xml` alone, or the index at that path first and its parts after. */
|
|
76
95
|
readonly sitemaps: readonly SitemapFile[];
|
|
77
96
|
}
|
|
@@ -89,8 +108,9 @@ const isPublicSite = (entry: RouteEntry): boolean =>
|
|
|
89
108
|
const pagesOf = async (
|
|
90
109
|
entry: RouteEntry,
|
|
91
110
|
options: SiteSeoOptions,
|
|
111
|
+
enumerate: Enumerate,
|
|
92
112
|
): Promise<readonly SitemapPage[]> => {
|
|
93
|
-
const said = (await
|
|
113
|
+
const said = (await enumerate(entry)).map(
|
|
94
114
|
(page): SitemapPage => ({
|
|
95
115
|
path: fillPath(entry.pattern.source, page.params),
|
|
96
116
|
...(page.lastmod === undefined ? {} : { lastmod: page.lastmod }),
|
|
@@ -147,6 +167,7 @@ async function publicSiteRoutes(
|
|
|
147
167
|
options: SiteSeoOptions,
|
|
148
168
|
stamp: Stamp,
|
|
149
169
|
locales: readonly string[],
|
|
170
|
+
enumerate: Enumerate,
|
|
150
171
|
): Promise<readonly RouteRecord[]> {
|
|
151
172
|
const indexed = await indexability(locales);
|
|
152
173
|
return routeEntries()
|
|
@@ -158,7 +179,7 @@ async function publicSiteRoutes(
|
|
|
158
179
|
render: entry.config.render,
|
|
159
180
|
...indexed(entry.path),
|
|
160
181
|
...(isDynamic(entry.path) || entry.config.prerender !== undefined
|
|
161
|
-
? { prerender: () => pagesOf(entry, options) }
|
|
182
|
+
? { prerender: () => pagesOf(entry, options, enumerate) }
|
|
162
183
|
: {}),
|
|
163
184
|
...stamp(entry.file),
|
|
164
185
|
}));
|
|
@@ -166,6 +187,22 @@ async function publicSiteRoutes(
|
|
|
166
187
|
|
|
167
188
|
type Stamp = (file: string) => { readonly lastmod?: string };
|
|
168
189
|
|
|
190
|
+
/** `enumeratePrerenderEntries`, asked once per route per answer: the sitemap and `llms.txt` share it. */
|
|
191
|
+
type Enumerate = (entry: RouteEntry) => Promise<readonly PrerenderEntry[]>;
|
|
192
|
+
|
|
193
|
+
const enumerateOnce = (given: RouteEntries | undefined): Enumerate => {
|
|
194
|
+
const asked = new Map<RouteEntry, Promise<readonly PrerenderEntry[]>>();
|
|
195
|
+
return (entry) => {
|
|
196
|
+
const enumerated = given?.get(entry.path);
|
|
197
|
+
if (enumerated !== undefined) return Promise.resolve(enumerated);
|
|
198
|
+
const known = asked.get(entry);
|
|
199
|
+
if (known !== undefined) return known;
|
|
200
|
+
const answer = enumeratePrerenderEntries(entry);
|
|
201
|
+
asked.set(entry, answer);
|
|
202
|
+
return answer;
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
169
206
|
/**
|
|
170
207
|
* The app's `seo.sitemap.extra` paths as records, each resolved to the route that answers it — the
|
|
171
208
|
* most specific match, the router's own tie-break. REFUSED, never skipped, when it names no route,
|
|
@@ -216,8 +253,9 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
|
216
253
|
const lastmod = lastmodOf(file, settings.lastmod, root);
|
|
217
254
|
return lastmod === undefined ? {} : { lastmod };
|
|
218
255
|
};
|
|
256
|
+
const enumerate = enumerateOnce(options.entries);
|
|
219
257
|
const routes = [
|
|
220
|
-
...(await publicSiteRoutes(options, stamp, locales)),
|
|
258
|
+
...(await publicSiteRoutes(options, stamp, locales, enumerate)),
|
|
221
259
|
...extraRoutes(settings.extra, stamp),
|
|
222
260
|
];
|
|
223
261
|
const sitemap = await buildSitemap(routes, {
|
|
@@ -245,5 +283,16 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
|
245
283
|
...(options.robotsGroups === undefined ? {} : { groups: options.robotsGroups }),
|
|
246
284
|
...(options.environment === undefined ? {} : { environment: options.environment }),
|
|
247
285
|
});
|
|
248
|
-
|
|
286
|
+
// The same pages, default locale, less what `robots.txt` keeps crawlers out of (`site-llms.ts`).
|
|
287
|
+
const llms = await siteLlms({
|
|
288
|
+
baseUrl: options.baseUrl,
|
|
289
|
+
environment: options.environment,
|
|
290
|
+
enabled: options.llms,
|
|
291
|
+
disallow: options.disallow,
|
|
292
|
+
pagesFor: options.pagesFor,
|
|
293
|
+
defaultLocale,
|
|
294
|
+
enumerate,
|
|
295
|
+
pages: options.llmsPages,
|
|
296
|
+
});
|
|
297
|
+
return { robots, sitemaps, llms };
|
|
249
298
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The newest mtime over what a static build reads, for `budgets`' freshness: the app's source files
|
|
2
|
+
// (tests and declarations ship no bytes), the config and the lockfile, and every directory those
|
|
3
|
+
// files sit in — a deleted page changes no remaining file's mtime, only its directory's.
|
|
4
|
+
|
|
5
|
+
// why: Bun exposes no path-join or dirname primitive.
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { APP_CONFIG_FILE } from '@ultimat3/core';
|
|
8
|
+
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Files outside `SOURCE_GLOBS` that still move a route's bytes: the config (pwa, theme, surfaces)
|
|
12
|
+
* and the lockfile, whose change is a framework or dependency upgrade.
|
|
13
|
+
*/
|
|
14
|
+
const BYTE_INPUTS = [APP_CONFIG_FILE, 'bun.lock'] as const;
|
|
15
|
+
|
|
16
|
+
/** `0` for a path that vanished between the glob and the stat: it cannot be newer than anything. */
|
|
17
|
+
const mtimeOf = async (path: string): Promise<number> => {
|
|
18
|
+
const file = Bun.file(path);
|
|
19
|
+
try {
|
|
20
|
+
return (await file.stat()).mtimeMs;
|
|
21
|
+
} catch {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Every directory between `path` and the root, the root itself excluded (`.x/` lives there). */
|
|
27
|
+
const ancestorsOf = (path: string): readonly string[] => {
|
|
28
|
+
const out: string[] = [];
|
|
29
|
+
for (let dir = dirname(path); dir !== '.' && dir !== '' && dir !== '/'; dir = dirname(dir)) {
|
|
30
|
+
out.push(dir);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The newest mtime, in ms, or `undefined` for an app with no source at all. */
|
|
36
|
+
export async function newestSourceMtime(root: string): Promise<number | undefined> {
|
|
37
|
+
let newest: number | undefined;
|
|
38
|
+
const consider = (ms: number): void => {
|
|
39
|
+
if (ms > 0 && (newest === undefined || ms > newest)) newest = ms;
|
|
40
|
+
};
|
|
41
|
+
const dirs = new Set<string>();
|
|
42
|
+
for await (const path of eachSourceFile(root)) {
|
|
43
|
+
if (isTest(path) || isGenerated(path)) continue;
|
|
44
|
+
consider(await mtimeOf(join(root, path)));
|
|
45
|
+
for (const dir of ancestorsOf(path)) dirs.add(dir);
|
|
46
|
+
}
|
|
47
|
+
for (const dir of dirs) consider(await mtimeOf(join(root, dir)));
|
|
48
|
+
for (const path of BYTE_INPUTS) consider(await mtimeOf(join(root, path)));
|
|
49
|
+
return newest;
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The three ways a static export's FILES — image variants and social cards — cannot be made: a
|
|
2
|
+
// `src` the export does not hold, a card path no exported page answers, and no browser to take the
|
|
3
|
+
// card with. Codes and titles are `error-codes.ts`'s; these are the constructors.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
const listed = (values: readonly string[]): string =>
|
|
8
|
+
values.map((value) => renderCauseValue(value)).join(', ');
|
|
9
|
+
|
|
10
|
+
export class ImageSourceUnreadableError extends UltimateError {
|
|
11
|
+
constructor(srcs: readonly string[]) {
|
|
12
|
+
super({
|
|
13
|
+
code: 'X_IMAGE_SOURCE_UNREADABLE',
|
|
14
|
+
cause: `responsiveImage() on an exported page named ${listed(srcs)}, and the static export holds no such file, so no variant of it can be written — a CDN would answer every <source> with a 404`,
|
|
15
|
+
fix: "x build --target static --json # lists the export; put the file under apps/web/site/assets/ and pass asset('assets/<file>') as src, or give an absolute https:// URL",
|
|
16
|
+
meta: { srcs: [...srcs] },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CardRouteUnknownError extends UltimateError {
|
|
22
|
+
constructor(paths: readonly string[], declaredIn?: string) {
|
|
23
|
+
super({
|
|
24
|
+
code: 'X_SEO_CARD_ROUTE_UNKNOWN',
|
|
25
|
+
cause: `og.capture names ${listed(paths)}${declaredIn === undefined ? '' : ` (in ${declaredIn})`}, and no exported site/ page answers that path, so there is no page to photograph`,
|
|
26
|
+
fix: 'x routes --json # list the site/ routes; add apps/web/site/<the capture path>/page.tsx at 1200 x 630 with robots: { index: false }, or correct the og.capture path',
|
|
27
|
+
meta: { paths: [...paths] },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class CardBrowserMissingError extends UltimateError {
|
|
33
|
+
constructor(paths: readonly string[]) {
|
|
34
|
+
super({
|
|
35
|
+
code: 'X_SEO_CARD_BROWSER_MISSING',
|
|
36
|
+
cause: `x build --target static photographs ${listed(paths)} as social cards, and no browser was named or found — no CHROME_PATH, PUPPETEER_EXECUTABLE_PATH or SCRAPE_CDP_URL is set and no probed path holds one`,
|
|
37
|
+
fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome, Chromium or Edge binary; or drop og.capture from the route; the gate build passes --no-capture and needs none',
|
|
38
|
+
meta: { paths: [...paths] },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Single responsibility: install `@ultimat3/seo`'s static seam for one export, and after the render
|
|
2
|
+
// loop write the files the exported documents promised — image variants (`static-images.ts`) and
|
|
3
|
+
// social cards (`static-cards.ts`). The prerenderer's one touch point for both.
|
|
4
|
+
|
|
5
|
+
// why: Bun exposes no path-join primitive, and every page file is relative to the export root.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { setStaticExport } from '@ultimat3/seo';
|
|
8
|
+
import type { ShotDriver } from './browser-launcher-port';
|
|
9
|
+
import { BUILD_CAPTURE_ENV } from './build-capture-env';
|
|
10
|
+
import { CARD_SIZE, staticCards } from './static-cards';
|
|
11
|
+
import { staticImages } from './static-images';
|
|
12
|
+
|
|
13
|
+
export interface StaticCaptureFiles {
|
|
14
|
+
readonly images: readonly string[];
|
|
15
|
+
readonly cards: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StaticCapture {
|
|
19
|
+
/** Uninstalls the seam. Idempotent; runs in the render loop's `finally`. */
|
|
20
|
+
close(): void;
|
|
21
|
+
write(
|
|
22
|
+
pages: readonly { readonly path: string; readonly file: string }[],
|
|
23
|
+
): Promise<StaticCaptureFiles>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The CLI's own CDP driver, or `undefined` when nothing can be launched or attached to. */
|
|
27
|
+
async function cardBrowser(): Promise<ShotDriver | undefined> {
|
|
28
|
+
const { appBrowser, cdpUrlFrom, executablePathFrom } = await import('./browser-launcher');
|
|
29
|
+
const cdpUrl = cdpUrlFrom(undefined, Bun.env);
|
|
30
|
+
const executablePath = executablePathFrom(undefined, Bun.env);
|
|
31
|
+
if (cdpUrl === undefined && executablePath === undefined) return undefined;
|
|
32
|
+
return appBrowser({ cdpUrl, executablePath, viewport: CARD_SIZE });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function staticCapture(
|
|
36
|
+
out: string,
|
|
37
|
+
capture: boolean = Bun.env[BUILD_CAPTURE_ENV] !== 'off',
|
|
38
|
+
browser: () => Promise<ShotDriver | undefined> = cardBrowser,
|
|
39
|
+
): Promise<StaticCapture> {
|
|
40
|
+
const images = await staticImages(out);
|
|
41
|
+
const cards = staticCards();
|
|
42
|
+
setStaticExport({ imageUrl: images.imageUrl, cardImage: cards.cardImage });
|
|
43
|
+
return {
|
|
44
|
+
close: () => setStaticExport(undefined),
|
|
45
|
+
async write(pages) {
|
|
46
|
+
const documents = await Promise.all(
|
|
47
|
+
pages.map((page) => Bun.file(join(out, page.file)).text()),
|
|
48
|
+
);
|
|
49
|
+
const exported = new Set(pages.map((page) => page.path));
|
|
50
|
+
return {
|
|
51
|
+
images: await images.write(documents),
|
|
52
|
+
cards: await cards.write(out, documents, exported, { capture, browser }),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Single responsibility: the export, served on loopback for the length of a card capture — the
|
|
2
|
+
// dumb file server a CDN is, so a card page is photographed with exactly the stylesheet, fonts and
|
|
3
|
+
// assets the published page links. Never the app: a static export has no process behind it.
|
|
4
|
+
|
|
5
|
+
// why: Bun exposes no path-join primitive, and every file served is relative to the export root.
|
|
6
|
+
import { join, normalize, sep } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export interface ServedExport {
|
|
9
|
+
readonly origin: string;
|
|
10
|
+
stop(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `/og/home` → `og/home/index.html`, then the bare file — the two shapes an export holds. */
|
|
14
|
+
function fileFor(out: string, pathname: string): readonly string[] {
|
|
15
|
+
let decoded: string;
|
|
16
|
+
try {
|
|
17
|
+
decoded = decodeURIComponent(pathname);
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const relative = normalize(decoded).replace(/^[/\\]+/, '');
|
|
22
|
+
if (relative.split(sep).includes('..')) return [];
|
|
23
|
+
return [join(out, relative, 'index.html'), join(out, relative)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function serveExport(out: string): ServedExport {
|
|
27
|
+
const server = Bun.serve({
|
|
28
|
+
hostname: '127.0.0.1',
|
|
29
|
+
port: 0,
|
|
30
|
+
async fetch(request) {
|
|
31
|
+
for (const file of fileFor(out, new URL(request.url).pathname)) {
|
|
32
|
+
const body = Bun.file(file);
|
|
33
|
+
if (await body.exists()) return new Response(body);
|
|
34
|
+
}
|
|
35
|
+
return new Response(null, { status: 404 });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return { origin: `http://127.0.0.1:${String(server.port)}`, stop: () => void server.stop(true) };
|
|
39
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Single responsibility: social cards as FILES in a static export. While it renders, `og.capture`
|
|
2
|
+
// is answered as `/_og/<path>.png` at 1200 x 630 and recorded; after it, each card an exported
|
|
3
|
+
// document names is photographed from its exported page over the CLI's own CDP driver. Under
|
|
4
|
+
// `--no-capture` (the gate's build) the tags are stamped and no PNG is written.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and every card path is relative to the export root.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { probeImage, transformImageBytes } from '@ultimat3/core';
|
|
9
|
+
import type { OpenGraphImage } from '@ultimat3/seo';
|
|
10
|
+
import type { ShotDriver } from './browser-launcher-port';
|
|
11
|
+
import { DEFAULT_PAGE_TIMEOUT_MS, systemShotClock } from './cdp-shot-clock';
|
|
12
|
+
import { CardBrowserMissingError, CardRouteUnknownError } from './static-capture-errors';
|
|
13
|
+
import { serveExport } from './static-cards-serve';
|
|
14
|
+
|
|
15
|
+
/** The size every platform crops a link preview from. */
|
|
16
|
+
export const CARD_SIZE = Object.freeze({ width: 1200, height: 630 });
|
|
17
|
+
|
|
18
|
+
export const STATIC_CARD_DIR = '_og';
|
|
19
|
+
|
|
20
|
+
/** `/og/post/hello` → `/_og/og/post/hello.png`; `/` → `/_og/index.png`. */
|
|
21
|
+
export function cardUrl(capturePath: string): string {
|
|
22
|
+
const trimmed = capturePath.replace(/\/+$/, '');
|
|
23
|
+
return `/${STATIC_CARD_DIR}${trimmed === '' ? '/index' : trimmed}.png`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CardCaptureOptions {
|
|
27
|
+
/** `false` under `--no-capture`: the tags are stamped, no browser is asked, no PNG is written. */
|
|
28
|
+
readonly capture: boolean;
|
|
29
|
+
/** The browser, asked only when a card has to be taken. `undefined` means none is available. */
|
|
30
|
+
readonly browser: () => Promise<ShotDriver | undefined>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StaticCards {
|
|
34
|
+
/** The `og:image` the seo seam stamps for one `og.capture`. Never throws. */
|
|
35
|
+
cardImage(capturePath: string): OpenGraphImage;
|
|
36
|
+
/**
|
|
37
|
+
* The cards exported documents name: each capture path must be an exported page
|
|
38
|
+
* (`X_SEO_CARD_ROUTE_UNKNOWN`), then each is photographed unless capture is off. Answers the
|
|
39
|
+
* PNG site paths written.
|
|
40
|
+
*/
|
|
41
|
+
write(
|
|
42
|
+
out: string,
|
|
43
|
+
documents: readonly string[],
|
|
44
|
+
exportedPaths: ReadonlySet<string>,
|
|
45
|
+
options: CardCaptureOptions,
|
|
46
|
+
): Promise<readonly string[]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Exactly 1200 x 630 on disk, whatever the device pixel ratio did: the stamped size is a promise. */
|
|
50
|
+
async function sized(png: Uint8Array): Promise<Uint8Array> {
|
|
51
|
+
const info = probeImage(png);
|
|
52
|
+
if (info.width === CARD_SIZE.width && info.height === CARD_SIZE.height) return png;
|
|
53
|
+
return transformImageBytes(png, { ...CARD_SIZE, fit: 'cover', format: 'png' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function staticCards(): StaticCards {
|
|
57
|
+
const captures = new Map<string, string>();
|
|
58
|
+
return {
|
|
59
|
+
cardImage(capturePath) {
|
|
60
|
+
const url = cardUrl(capturePath);
|
|
61
|
+
captures.set(url, capturePath);
|
|
62
|
+
return { url, ...CARD_SIZE, type: 'image/png' };
|
|
63
|
+
},
|
|
64
|
+
async write(out, documents, exportedPaths, options) {
|
|
65
|
+
const named = [...captures].filter(([url]) => documents.some((html) => html.includes(url)));
|
|
66
|
+
const unknown = named.map(([, path]) => path).filter((path) => !exportedPaths.has(path));
|
|
67
|
+
if (unknown.length > 0) throw new CardRouteUnknownError(unknown);
|
|
68
|
+
if (!options.capture || named.length === 0) return [];
|
|
69
|
+
const driver = await options.browser();
|
|
70
|
+
if (driver === undefined) throw new CardBrowserMissingError(named.map(([, path]) => path));
|
|
71
|
+
const served = serveExport(out);
|
|
72
|
+
const session = await driver.open({
|
|
73
|
+
name: 'x build cards',
|
|
74
|
+
rules: { allowHosts: ['127.0.0.1'] },
|
|
75
|
+
clock: systemShotClock,
|
|
76
|
+
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
77
|
+
viewport: CARD_SIZE,
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
const written: string[] = [];
|
|
81
|
+
for (const [url, path] of named) {
|
|
82
|
+
await session.page.goto(new URL(path, served.origin).href);
|
|
83
|
+
await Bun.write(join(out, url.slice(1)), await sized(await session.page.screenshot({})));
|
|
84
|
+
written.push(url);
|
|
85
|
+
}
|
|
86
|
+
return written;
|
|
87
|
+
} finally {
|
|
88
|
+
await session.close();
|
|
89
|
+
served.stop();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/static-document.ts
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
// per request, nor a body per revalidation. No other mode comes through here.
|
|
5
5
|
|
|
6
6
|
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
-
import { html } from '@ultimat3/http';
|
|
7
|
+
import { html, notModified } from '@ultimat3/http';
|
|
8
8
|
import type { RenderResult, RouteEntry } from '@ultimat3/render';
|
|
9
|
-
import { etagMatches } from './runtime-storage';
|
|
10
9
|
|
|
11
10
|
/** A rendered static document: always a string body, never a stream. */
|
|
12
11
|
export type StaticResult = RenderResult & { readonly body: string };
|
|
@@ -78,19 +77,24 @@ export function createStaticMemo(
|
|
|
78
77
|
/**
|
|
79
78
|
* The key a request's document is kept under, or `undefined` when it must be rendered: a query
|
|
80
79
|
* string is part of what `meta` reads (`data.url`), and a dynamic segment is an unbounded set an
|
|
81
|
-
* anonymous visitor chooses. The origin is in the key
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
80
|
+
* anonymous visitor chooses. The request's origin is in the key only when the app declares none,
|
|
81
|
+
* because canonical and `og:url` are then absolute against it; declared, they are absolute against
|
|
82
|
+
* `origin` (`runtime-render-document.ts`'s `baseUrl`), the document is one whatever the `Host`, and
|
|
83
|
+
* keying on the request's let rotated `Host` headers fill the memo. `url` is `addressedUrl`'s:
|
|
84
|
+
* keyed on the pathname as written, any visitor filled the memo with spellings of one page
|
|
85
|
+
* (`/about//`, …) and every page that arrived after the cap was rendered per request for the life
|
|
86
|
+
* of the process.
|
|
85
87
|
*/
|
|
86
88
|
export function staticMemoKey(
|
|
87
89
|
entry: Pick<RouteEntry, 'config' | 'pattern'>,
|
|
88
90
|
url: URL,
|
|
89
91
|
locale: string,
|
|
92
|
+
/** The app's declared origin — the same value the document's head is built against. */
|
|
93
|
+
origin?: string,
|
|
90
94
|
): string | undefined {
|
|
91
95
|
if (entry.config.render !== 'static') return undefined;
|
|
92
96
|
if (entry.pattern.keys.length > 0 || url.search !== '') return undefined;
|
|
93
|
-
return `${locale}\n${url.origin}${url.pathname}`;
|
|
97
|
+
return `${locale}\n${origin ?? url.origin}${url.pathname}`;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
/**
|
|
@@ -100,13 +104,10 @@ export function staticMemoKey(
|
|
|
100
104
|
*/
|
|
101
105
|
export function staticResponse(request: UltimateRequest, result: StaticResult): Response {
|
|
102
106
|
const etag = result.headers['etag'];
|
|
103
|
-
if (
|
|
104
|
-
result.status === 200 &&
|
|
105
|
-
etag !== undefined &&
|
|
106
|
-
etagMatches(request.header('if-none-match'), etag)
|
|
107
|
-
) {
|
|
107
|
+
if (result.status === 200 && etag !== undefined) {
|
|
108
108
|
const { 'content-type': _dropped, ...headers } = result.headers;
|
|
109
|
-
|
|
109
|
+
const fresh = notModified(request, etag, headers);
|
|
110
|
+
if (fresh !== undefined) return fresh;
|
|
110
111
|
}
|
|
111
112
|
return html(result.body, { status: result.status, headers: result.headers });
|
|
112
113
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Single responsibility: the image variants a static export's documents promise, as FILES. While
|
|
2
|
+
// the export renders, `responsiveImage` asks this module for each variant's URL — `/_img/<source
|
|
3
|
+
// hash>-<width>.<ext>` — and after it, every URL an exported document names is decoded, resized
|
|
4
|
+
// and written through core's pipeline. A dumb file server then answers `image/webp` for a webp.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and every variant path is relative to the export root.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { canEncode, imageUnsupported, sniffImageFormat, transformImageBytes } from '@ultimat3/core';
|
|
9
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
10
|
+
import type { StaticImageRequest } from '@ultimat3/seo';
|
|
11
|
+
import { ImageSourceUnreadableError } from './static-capture-errors';
|
|
12
|
+
|
|
13
|
+
/** Where every variant lands, and the prefix a document names it by. */
|
|
14
|
+
export const STATIC_IMAGE_DIR = '_img';
|
|
15
|
+
|
|
16
|
+
/** The source encodings an export can read and resize — core's decoders, minus SVG markup. */
|
|
17
|
+
const SOURCE_GLOB = new Bun.Glob('**/*.{png,jpg,jpeg,webp,gif}');
|
|
18
|
+
|
|
19
|
+
interface Source {
|
|
20
|
+
readonly file: string;
|
|
21
|
+
readonly hash: string;
|
|
22
|
+
/** What the variant is written as when the document named no format. */
|
|
23
|
+
readonly ownFormat: 'png' | 'jpeg' | 'webp';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Variant {
|
|
27
|
+
readonly src: string;
|
|
28
|
+
readonly width: number;
|
|
29
|
+
/** `undefined` for a source the export does not hold: the URL is recorded, never written. */
|
|
30
|
+
readonly source: Source | undefined;
|
|
31
|
+
readonly format: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StaticImages {
|
|
35
|
+
/** The URL builder `@ultimat3/seo`'s static seam calls. Never throws: refusals wait for `write`. */
|
|
36
|
+
imageUrl(request: StaticImageRequest): string;
|
|
37
|
+
/**
|
|
38
|
+
* Writes every recorded variant an exported document names, and answers their site paths. A
|
|
39
|
+
* named variant of a `src` the export does not hold is `X_IMAGE_SOURCE_UNREADABLE`; one in a
|
|
40
|
+
* format the pipeline cannot encode (`avif`) is core's `X_IMAGE_UNSUPPORTED` — a build error,
|
|
41
|
+
* never a dead `<source>`. URLs only weighed documents named are never written.
|
|
42
|
+
*/
|
|
43
|
+
write(documents: readonly string[]): Promise<readonly string[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const EXTENSION: ReadonlyMap<string, string> = new Map([
|
|
47
|
+
['png', 'png'],
|
|
48
|
+
['jpeg', 'jpg'],
|
|
49
|
+
['webp', 'webp'],
|
|
50
|
+
['avif', 'avif'],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** The site path a `src` names, without a query or fragment — the file it is in the export. */
|
|
54
|
+
const pathOf = (src: string): string => src.replace(/[?#].*$/, '');
|
|
55
|
+
|
|
56
|
+
/** Index the images the export already holds (`asset()` copies, icons), by their site path. */
|
|
57
|
+
async function indexSources(out: string): Promise<ReadonlyMap<string, Source>> {
|
|
58
|
+
const sources = new Map<string, Source>();
|
|
59
|
+
for await (const relative of SOURCE_GLOB.scan({ cwd: out, onlyFiles: true })) {
|
|
60
|
+
const site = `/${relative.split('\\').join('/')}`;
|
|
61
|
+
if (site.startsWith(`/${STATIC_IMAGE_DIR}/`)) continue;
|
|
62
|
+
const file = join(out, relative);
|
|
63
|
+
const bytes = await Bun.file(file).bytes();
|
|
64
|
+
const format = sniffImageFormat(bytes);
|
|
65
|
+
if (format === null) continue;
|
|
66
|
+
sources.set(site, {
|
|
67
|
+
file,
|
|
68
|
+
hash: contentHash(bytes),
|
|
69
|
+
ownFormat: format === 'jpeg' || format === 'webp' ? format : 'png',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return sources;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function staticImages(out: string): Promise<StaticImages> {
|
|
76
|
+
const sources = await indexSources(out);
|
|
77
|
+
const variants = new Map<string, Variant>();
|
|
78
|
+
return {
|
|
79
|
+
imageUrl({ src, width, format }) {
|
|
80
|
+
const source = sources.get(pathOf(src));
|
|
81
|
+
const resolved = format ?? source?.ownFormat ?? 'png';
|
|
82
|
+
// A src the export lacks still gets a stable URL, so `write` can tell which document named it.
|
|
83
|
+
const stem = source === undefined ? `missing-${contentHash(src)}` : source.hash;
|
|
84
|
+
const url = `/${STATIC_IMAGE_DIR}/${stem}-${String(width)}.${EXTENSION.get(resolved) ?? resolved}`;
|
|
85
|
+
variants.set(url, { src, width, source, format: resolved });
|
|
86
|
+
return url;
|
|
87
|
+
},
|
|
88
|
+
async write(documents) {
|
|
89
|
+
const named = [...variants].filter(([url]) => documents.some((html) => html.includes(url)));
|
|
90
|
+
const missing = [
|
|
91
|
+
...new Set(named.filter(([, one]) => one.source === undefined).map(([, one]) => one.src)),
|
|
92
|
+
];
|
|
93
|
+
if (missing.length > 0) throw new ImageSourceUnreadableError(missing);
|
|
94
|
+
const refused = named.find(([, one]) => !canEncode(one.format));
|
|
95
|
+
if (refused !== undefined) {
|
|
96
|
+
throw imageUnsupported(
|
|
97
|
+
`a static page asked responsiveImage() for ${refused[1].format}, which the build pipeline cannot encode, so its <source> would name a file no build writes`,
|
|
98
|
+
"responsiveImage(input, { formats: ['webp'] }) # drop avif from a static route; a CDN driver serves it at runtime",
|
|
99
|
+
{ format: refused[1].format, src: refused[1].src },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const written: string[] = [];
|
|
103
|
+
for (const [url, one] of named) {
|
|
104
|
+
if (one.source === undefined || !canEncode(one.format)) continue;
|
|
105
|
+
const bytes = await transformImageBytes(await Bun.file(one.source.file).bytes(), {
|
|
106
|
+
width: one.width,
|
|
107
|
+
format: one.format,
|
|
108
|
+
});
|
|
109
|
+
await Bun.write(join(out, url.slice(1)), bytes);
|
|
110
|
+
written.push(url);
|
|
111
|
+
}
|
|
112
|
+
return written;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// What is ambient inside a `<Hole>` of a `stream` page: the request's context, with the hole's own
|
|
2
|
+
// signal (its deadline, the client's disconnect, the document's close) in place of the request's,
|
|
3
|
+
// and the response headers sealed — they left with the first flush, so a write there was a cookie
|
|
4
|
+
// or a `Location` nobody would ever receive.
|
|
5
|
+
|
|
6
|
+
import { runWithContext, tryUseContext, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { HoleEnter } from '@ultimat3/render/server';
|
|
8
|
+
|
|
9
|
+
const FIX =
|
|
10
|
+
'load: async () => { setCookie(name, value); return data; }, // in defineRoute, never inside a Hole';
|
|
11
|
+
|
|
12
|
+
/** A response header or redirect written under a hole, after the status and headers were sent. */
|
|
13
|
+
export class StreamAlreadyFlushedError extends UltimateError {
|
|
14
|
+
constructor(what: string) {
|
|
15
|
+
super({
|
|
16
|
+
code: 'X_STREAM_ALREADY_FLUSHED',
|
|
17
|
+
cause: `${what} ran inside a <Hole> on a render: 'stream' page, after the status and headers were sent with the first flush, so the browser would never receive it`,
|
|
18
|
+
fix: FIX,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every write to the response headers refuses; every read answers what was sent. */
|
|
24
|
+
const sealedHeaders = (headers: Headers): Headers =>
|
|
25
|
+
new Proxy(headers, {
|
|
26
|
+
get(target, key) {
|
|
27
|
+
if (key === 'append' || key === 'set' || key === 'delete') {
|
|
28
|
+
return (name: string) => {
|
|
29
|
+
throw new StreamAlreadyFlushedError(`writing the ${name} response header (setCookie)`);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const value: unknown = Reflect.get(target, key, target);
|
|
33
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The scope a hole's children run in. Forwarded rather than rebuilt, as `runToCompletion` forwards a
|
|
39
|
+
* request's context: a rebuilt `ctxOf` drops the http fields, and `useRequestHeader()` inside a hole
|
|
40
|
+
* would then refuse. Services the request built keep the request's signal; the ambient one is the hole's.
|
|
41
|
+
*/
|
|
42
|
+
export const enterHole: HoleEnter = (signal, run) => {
|
|
43
|
+
const parent = tryUseContext();
|
|
44
|
+
if (parent === undefined) return run();
|
|
45
|
+
const sealed = sealedHeaders((parent as { readonly headers?: Headers }).headers ?? new Headers());
|
|
46
|
+
const scope = new Proxy(parent, {
|
|
47
|
+
get: (target, key) =>
|
|
48
|
+
key === 'signal' ? signal : key === 'headers' ? sealed : Reflect.get(target, key),
|
|
49
|
+
set: (target, key, value) => {
|
|
50
|
+
if (key === 'redirect') throw new StreamAlreadyFlushedError('setRedirect()');
|
|
51
|
+
return Reflect.set(target, key, value);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return runWithContext(scope, run);
|
|
55
|
+
};
|
package/src/style-bundle.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// asset over (`island-bundle.ts`), because the two answer the same question — "what does this
|
|
4
4
|
// document make the browser fetch, and can it keep it?" — and one mechanism is the whole point.
|
|
5
5
|
//
|
|
6
|
-
// Why it stopped being an inline `<style>`: measured against
|
|
6
|
+
// Why it stopped being an inline `<style>`: measured against one adopter's app on 2026-09-06, every
|
|
7
7
|
// `app/` document carried one 156,738-byte block — 1,336 rules from 406 module sources, byte
|
|
8
8
|
// identical across `/`, `/fleet`, `/fleet/[host]` and the session page — inside a response the
|
|
9
9
|
// pipeline sends `Cache-Control: private, no-store`. 92% of the dashboard document and 89% of the
|