Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,34 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

## [Unreleased]

Nothing yet.
### Added

- **cli:** the web role serves `GET /robots.txt` and `GET /sitemap.xml` in `x dev` and in
`runRole`. Until now only the static export wrote them: a `ROLE=web` container answered 404 for
both, and an app has no way to add a non-page GET route. Both come from the new `siteSeo()`
(exported from `@ultimat3/cli`): the public `site/` routes (no `policy`), minus any page whose
`meta` says `robots: { index: false }`, dynamic routes expanded through `prerender()`. URLs are
absolute against `APP_URL`, else `SITE_ORIGIN`, else the request's origin. Production allows the
crawl and names the absolute sitemap; anything else is `Disallow: /`. The scaffolded
`apps/web/prerender.ts` now writes `siteSeo()`'s files, so the export and the process serve the
same two files. An app scaffolded earlier keeps its own `writeSeoFiles`; replace its body with
`siteSeo({ baseUrl, pagesFor })` to match. Past 50,000 URLs the web role serves the index but
not the `/sitemap-N.xml` parts.

### Fixed

- **cli:** with `realtime: { enabled: false }`, documents no longer carry
`<meta name="ultimate-sync">`, the sync-worker meta or the page-boot script, and the worker and
boot routes are not mounted. No sync node is started in that case, so the page runtime was handed
a target nothing served.
- **cli:** `x g job` / `x g task` no longer corrupt `apps/web/api/index.ts` when its `jobs:` or
`tasks:` list is already one entry per line, which is the shape the generator itself writes once
a list passes 100 columns. The rewrite nested a second `jobs: [` inside the first and left the old
`]` behind. Lists are now also searched only inside the `defineApi({` call.
- **admin:** the `/_x` dev dashboard renders its tabs and questions in the framework's own locale.
In an app whose default locale is not `en`, every tab read `⟦dev.panel.mail.title⟧`. The
framework catalog is registered under `en` only, and the shell translated through the app's
ambient locale.

## 22.2.1 - 2026-09-25

Expand Down
20 changes: 20 additions & 0 deletions packages/admin/src/dev/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { configureLocales, resetLocaleConfig } from '@ultimat3/i18n';
import { staticDevSources } from './data';
import { assertDevOnly, DEV_PANELS, devDashboard, devShellStyle } from './server';

Expand Down Expand Up @@ -170,3 +171,22 @@ describe('devShellStyle', () => {
expect(await html?.text()).toContain(`<style>${style}</style>`);
});
});

// Reported by an app whose default locale is `es-co`: every tab and question on /_x read
// `⟦dev.panel.mail.title⟧`. The framework's strings are registered under `en` only, on purpose, and
// the shell rendered through the AMBIENT locale — the app's. /_x is the framework's own English
// tool (`<html lang="en">`), so it reads the framework catalog in its own locale.
describe('/_x in an app whose locale is not en', () => {
test('every tab and the question render as text, never as a missing key', async () => {
configureLocales({ supported: ['es-co', 'en'], fallback: 'es-co' });
try {
const dashboard = devDashboard({ role: 'web', env: 'development', sources });
const html = await (await dashboard.handle(new Request('http://x/_x/mail')))?.text();
expect(html).toContain('>Mail</a>');
expect(html).toContain('what did that email look like, in that locale?');
expect(html).not.toContain('⟦');
} finally {
resetLocaleConfig();
}
});
});
9 changes: 8 additions & 1 deletion packages/admin/src/dev/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Type-only, so it is erased and the 46-component barrel stays out of the mount graph — the
// values arrive through the dynamic `import()` in `devShellStyle()`, same reason as `data.ts`.
import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
import { t } from '@ultimat3/i18n';
import { FRAMEWORK_CATALOG_LOCALE, translatorFor } from '@ultimat3/i18n';
import type { ColorRole } from '@ultimat3/ui';
import { DevDashboardInProdError } from '../errors';
import { defaultDevSources } from './data';
Expand Down Expand Up @@ -157,6 +157,13 @@ html[data-theme="dark"] { ${block('dark')} }
${SHELL_LAYOUT}`;
}

/**
* /_x is the framework's own tool, in the framework's own locale (`<html lang="en">` below): its
* strings are registered under `FRAMEWORK_CATALOG_LOCALE` only, so the AMBIENT locale — the app's,
* `es-co` for an app that defaults to it — answered `⟦dev.panel.mail.title⟧` for every tab.
*/
const t = (key: string): string => translatorFor(FRAMEWORK_CATALOG_LOCALE)(key);

function shell(
style: string,
basePath: string,
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/api-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,79 @@ describe('unit · inserting into the scaffolded index', () => {
expect(insertApiEntries(foreign, entries)).toEqual({ source: foreign, skipped: entries });
});
});

// Reproduced from an app: `x g task <name> --feature <slice>` writes the task AND its job, and the
// app's `jobs:` list was already one entry per line — which is the shape this edit itself produces
// once a list passes 100 columns. The line-start lookup landed on the first ITEM rather than on
// `jobs: [`, so the rewrite nested a second `jobs: [` inside the first and orphaned its `]`.
describe('unit · a list already wrapped one entry per line', () => {
const WRAPPED = [
"import { defineApi } from '@ultimat3/action';",
"import * as anchorDayJob from '../app/evidence/jobs/anchor-day-job';",
"import * as anchorDay from '../app/evidence/tasks/anchor-day';",
"import * as health from './health';",
'',
'export const api = defineApi({',
' // Every primitive module under apps/web/app/*/{actions,queries,live,jobs,tasks}/, one entry each.',
' actions: [',
' health,',
' ],',
' jobs: [',
' anchorDayJob,',
' expireCreditsJob,',
' ],',
' tasks: [',
' anchorDay,',
' ],',
'});',
'',
].join('\n');

test('a task and its job land in their lists, each list still one well-formed literal', () => {
const entries = apiEntriesFor([
'apps/web/app/billing/tasks/purge-drafts.ts',
'apps/web/app/billing/jobs/purge-drafts-job.ts',
]);
const { source, skipped } = insertApiEntries(WRAPPED, entries);

expect(skipped).toEqual([]);
expect(source.match(/jobs: \[/g)).toHaveLength(1);
expect(source.match(/tasks: \[/g)).toHaveLength(1);
expect(source).toContain(' jobs: [anchorDayJob, expireCreditsJob, purgeDraftsJob],\n');
expect(source).toContain(' tasks: [anchorDay, purgeDrafts],\n');
expect(source).toContain(' actions: [\n health,\n ],\n');
expect(source.endsWith('});\n')).toBe(true);
// And it stays that way: the next run finds both names and changes nothing.
expect(insertApiEntries(source, entries).source).toBe(source);
});

// The scaffold's own index, grown one job at a time past the 100-column wrap — the shape every
// app reaches on its own, with nothing but this generator writing the list.
test('growing the scaffold index one job at a time keeps one jobs list, every job in it', () => {
const names = [
'alpha',
'bravo',
'charlie',
'delta',
'echo',
'foxtrot',
'golf',
'hotel',
'india',
'juliet',
'kilo',
'lima',
];
let source = indexOf(true);
for (const name of names) {
source = insertApiEntries(
source,
apiEntriesFor([`apps/web/app/${name}-slice/jobs/${name}-job.ts`]),
).source;
}
expect(source.match(/jobs: \[/g)).toHaveLength(1);
const list = /jobs: \[(?<body>[^\]]*)\]/.exec(source)?.groups?.['body'] ?? '';
for (const name of names) expect(list).toContain(`${name}Job`);
expect(list).toContain('reindexPost');
});
});
34 changes: 23 additions & 11 deletions packages/cli/src/api-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,35 @@ export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] {
});
}

/** Every `[...]` entry of a `key: [...]` list in the `defineApi({ ... })` call. */
/**
* Every `[...]` entry of a `key: [...]` list in the `defineApi({ ... })` call, and where it sits:
* `line` is the start of the line holding `key: [`, `start` is just past the `[`, `end` is the `]`.
*
* Searched from `from` (the `defineApi({` call), so a `jobs: [` in a comment or an object above the
* call is never the one edited. `line` is found from the key, never from `start`: in a list already
* wrapped one entry per line the character AT `start` is the newline after `[`, and a backwards
* search from there answered the first ITEM's line — the rewrite then nested a second `jobs: [`
* inside the first and left the old `]` behind.
*/
const listOf = (
source: string,
key: string,
): { start: number; end: number; items: string[] } | undefined => {
const open = new RegExp(`\\n(\\s*)${key}: \\[`).exec(source);
if (open === null) return undefined;
const start = open.index + open[0].length;
from: number,
): { line: number; start: number; end: number; items: string[] } | undefined => {
const open = new RegExp(`\\n([ \\t]*)${key}: \\[`, 'g');
open.lastIndex = from;
const found = open.exec(source);
if (found === null) return undefined;
const line = found.index + 1;
const start = found.index + found[0].length;
const end = source.indexOf(']', start);
if (end === -1) return undefined;
const items = source
.slice(start, end)
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
return { start, end, items };
return { line, start, end, items };
};

/**
Expand All @@ -68,12 +81,12 @@ export function insertApiEntries(
for (const entry of entries) {
const importLine = `import * as ${entry.binding} from '${entry.specifier}';`;
const call = next.indexOf('defineApi({');
const actions = listOf(next, 'actions');
const actions = call === -1 ? undefined : listOf(next, 'actions', call);
if (call === -1 || actions === undefined) {
skipped.push(entry);
continue;
}
const list = listOf(next, entry.key);
const list = listOf(next, entry.key, call);
if (list?.items.includes(entry.binding) === true) continue;
if (list === undefined) {
// After `actions: [...],` — the order `x new` writes: actions, queries, jobs, tasks.
Expand All @@ -82,10 +95,9 @@ export function insertApiEntries(
next = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`;
} else {
const items = [...list.items, entry.binding];
const lineStart = next.lastIndexOf('\n', list.start) + 1;
const indent = /^\s*/.exec(next.slice(lineStart))?.[0] ?? '';
const indent = /^[ \t]*/.exec(next.slice(list.line))?.[0] ?? '';
const rewritten = wrapList(indent, `${entry.key}: [`, items, ']');
next = `${next.slice(0, lineStart)}${rewritten}${next.slice(list.end + 1)}`;
next = `${next.slice(0, list.line)}${rewritten}${next.slice(list.end + 1)}`;
}
if (!next.includes(importLine)) next = withImport(next, importLine);
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cmd-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ async function bootDev(
storage: runtime.storage,
dashboard,
islands: () => state.islands,
realtime: runtime.realtime,
});

// The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
Expand Down
14 changes: 6 additions & 8 deletions packages/cli/src/cmd-new.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,15 +404,13 @@ describe('unit · x new · the API surface registers the app own primitives by n
* to answer — and `default: true` is a field only `--json` renders.
*/
describe('unit · x new · the sitemap and robots.txt it wires', () => {
// Decision D2: `@ultimat3/seo` had eleven exports with zero callers anywhere, and `buildSitemap`
// / `buildRobots` are the two the framework has no live equivalent for. They are wired into the
// STATIC ENTRY rather than into a route because both belong to the artifact: a static export is
// served with no process behind it, so a route answering them is a file the CDN never has.
test('the static entry builds both, and the app declares the package it builds them with', () => {
// Decision D2 wired `buildSitemap` / `buildRobots` into the STATIC ENTRY, because a static export
// is served with no process behind it. A container is served WITH one, and answered 404 for both
// until 22.2.2: the entry now writes `siteSeo`'s answer, the same one the web role serves.
test('the static entry writes both, from the answer the web role serves', () => {
const entry = emitted('apps/web/prerender.ts', true);
expect(entry).toContain("from '@ultimat3/seo'");
expect(entry).toContain('buildSitemap(');
expect(entry).toContain('buildRobots(');
expect(entry).toContain("from '@ultimat3/cli'");
expect(entry).toContain('siteSeo(');
expect(entry).toContain("'robots.txt'");
expect(emitted('package.json', true)).toContain('"@ultimat3/seo"');
});
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/dev-route-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// names, the island and sync-worker scripts, and the app's pages last. Split from `cmd-dev.ts` at its
// 500-line ceiling; `serve.ts` composes the production table from the same builders.

import type { RealtimeConfig } from '@ultimat3/core';
import type { Route } from '@ultimat3/http';
import { describeRoutes } from '@ultimat3/render';
import type { Storage } from '@ultimat3/storage';
Expand All @@ -19,6 +20,7 @@ import { loadPwaArtifacts } from './pwa-artifacts';
import { assetRoutes } from './runtime-assets';
import { appRoutes } from './runtime-render';
import { servedStorage, storageRoutes } from './runtime-storage';
import { seoRoutes } from './seo-routes';
import { styleBundle } from './style-bundle';
import { styleRoutes } from './style-routes';
import { serviceWorkerArtifacts } from './sw-artifacts';
Expand All @@ -34,6 +36,8 @@ export interface DevRouteTableInput {
readonly dashboard: DevDashboardInput;
/** A getter: the watcher tick rebuilds the islands, and a captured bundle would serve the first. */
readonly islands: () => IslandBundle;
/** `app.config.ts`'s `realtime`, as the boot obeyed it: off, no document names a sync node. */
readonly realtime: Pick<RealtimeConfig, 'enabled'>;
}

export interface DevRouteTable {
Expand All @@ -52,7 +56,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
const pwa = await loadPwaArtifacts(input.root);
const theme = themeBoot(await loadThemeMode(input.root));
// The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
const sync = await pageSync(input.root, input.env, input.buildId);
const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
const errorStyles = await errorPageStyleSources(input.root);
// Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
// changes under a page it controls is the update path, and one per keystroke exercises it per save.
Expand Down Expand Up @@ -96,6 +100,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
// captured at boot would answer 404 for the href the document now carries.
...styleRoutes(() => styleBundle()),
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
...seoRoutes({ env: input.env }),
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
// lives here rather than in a second server because everything it needs is in THIS process:
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
Expand All @@ -110,7 +116,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
...appRoutes({
buildId: input.buildId,
resolveIsland: (file) => input.islands().resolverFor(file),
sync: sync.head,
...(sync.head === undefined ? {} : { sync: sync.head }),
persisted: sync.persisted,
themeHead: theme.head,
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export {
serveApp,
} from './serve';
export { quoteArg } from './shell-quote';
export type { SiteSeo, SiteSeoOptions } from './site-seo';
export { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
export { eachSourceFile, isGenerated, isTest, SOURCE_GLOBS } from './source-files';
export type { SkippedRoute, StaticReport } from './static-report';
export { parseStaticReport } from './static-report';
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/page-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { afterAll, expect, test } from 'bun:test';
import { clearRegistry, entity, uuid } from '@ultimat3/entity';
import { pageSync } from './page-sync';

const ON = { enabled: true } as const;

afterAll(() => {
clearRegistry();
});

test('persisted names every entity declared persist: true, and only those, read at call time', async () => {
// OUTSIDE the checkout, so no realtime resolves and no worker is built — this is not about it.
const sync = await pageSync(Bun.env['TMPDIR'] ?? '/tmp', {}, 'build-under-test');
const sync = await pageSync(Bun.env['TMPDIR'] ?? '/tmp', {}, 'build-under-test', ON);
entity('page_sync_kept', { persist: true, columns: { id: uuid().primaryKey() } });
entity('page_sync_dropped', { columns: { id: uuid().primaryKey() } });

Expand All @@ -22,9 +24,21 @@ test('persisted names every entity declared persist: true, and only those, read

test('the scripts it serves are the scripts it hands the service worker to precache', async () => {
// The checkout's own realtime resolves from here, so both scripts are built.
const sync = await pageSync(`${import.meta.dir}/../../realtime`, {}, 'build-under-test');
const sync = await pageSync(`${import.meta.dir}/../../realtime`, {}, 'build-under-test', ON);
const urls = sync.scripts.map((script) => script.url);
expect(urls.some((url) => url.startsWith('/_x/page-boot/'))).toBe(true);
expect(urls.some((url) => url.startsWith('/_x/sync-worker/'))).toBe(true);
expect(sync.head.bootUrl).toBe(urls.find((url) => url.startsWith('/_x/page-boot/')) ?? '');
expect(sync.head?.bootUrl).toBe(urls.find((url) => url.startsWith('/_x/page-boot/')) ?? '');
});

// Reported by an app with `realtime: { enabled: false }`: no sync node is started (`role-realtime.ts`),
// yet every page still carried `<meta name="ultimate-sync" content="/_x/sync">` and the worker and
// boot scripts — a target nothing serves, for a page runtime to dial.
test('realtime off: no sync target, no worker, no boot script, nothing to precache', async () => {
const sync = await pageSync(`${import.meta.dir}/../../realtime`, {}, 'build-under-test', {
enabled: false,
});
expect(sync.head).toBeUndefined();
expect(sync.routes).toEqual([]);
expect(sync.scripts).toEqual([]);
});
Loading
Loading