@akanjs/devkit 3.2.0-rc.3 → 3.2.0-rc.6
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/CHANGELOG.md +80 -11
- package/akanApp/akanApp.host.test.ts +51 -0
- package/akanApp/akanApp.host.ts +3 -3
- package/akanContext.ts +2 -3
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +483 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +64 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.ts +12 -1
- package/codeAgent/skills/akan-debug/SKILL.md +6 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/command.ts +51 -33
- package/commandDecorators/commandDecorators.test.ts +74 -9
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +28 -0
- package/frontendBuild/clientEntryDiscovery.ts +25 -6
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +5 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/{constantFieldNames.test.ts → moduleFieldNames.test.ts} +24 -2
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewView.ts +32 -13
- package/preview/previewView.test.ts +33 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/workflow/plainName.ts +16 -3
- package/constantFieldNames.ts +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -131,13 +131,14 @@
|
|
|
131
131
|
- The dev builder draws the graph on request through a new `build-graph` builder RPC, and keeps it until the next save.
|
|
132
132
|
- It sits behind the same gate as the other `/_akan` devtools routes, and `akan tunnel` / `akan start --share` refuse it.
|
|
133
133
|
|
|
134
|
+
- 465ccb9: `@akanjs/devkit/apiReport/apiReport` renders a package's public API, one report per entry point, from its declaration emit. The akanjs repository now commits that report for `akanjs` and checks it in CI: a removed export or a changed declaration needs a `minor` changeset that says how to upgrade.
|
|
134
135
|
- 0504209: `akan build <app> --env <env>` builds for an environment other than the workspace `.env`'s, the way the desktop and mobile builds already do. Before, the shell's `AKAN_PUBLIC_ENV` lost to the `.env`, so a new workspace could only build `local` without editing it. The local-build notice, the image guard and the README now say what a `local` build changes in a built server: no endpoint rate limits, and local defaults for Redis and the database mode. They no longer say the dev routes are served, which a built server never does.
|
|
135
136
|
- bf872ce: A variable the shell sets now outranks the workspace `.env` when `akan` builds or runs an app, as dotenv and Bun's own `.env` loading do: `AKAN_PUBLIC_ENV=main akan build <app>` builds `main` where the `.env` says `local`. The order is `--env` (for `AKAN_PUBLIC_ENV`), then the process environment (the shell, and the `.env.local` and `.env.<NODE_ENV>` Bun loads into it), then the `.env`, then the defaults; an empty export counts as unset. The bundles, the bundled server env, `akan.env.json` and the Dockerfile still take one set of values. Where an exported value outranks the `.env`, `akan build` now warns with both values instead of the old warning that listed the values it ignored, and the server-env message, the `--env` help, the local-build notice, the README and the CLI reference state the new order.
|
|
136
137
|
|
|
137
138
|
**Behavior change:** where the shell exports a value the workspace `.env` also sets, such as `AKAN_PUBLIC_ENV`, `AKAN_PUBLIC_REPO_NAME` or `AKAN_PUBLIC_SERVE_DOMAIN`, `akan` now takes the shell's. Unset it, or match the `.env`, to build what you built before.
|
|
138
139
|
|
|
139
140
|
- e65172d: The CLI refreshes a cloud sign-in once less than an hour, or half the token's lifetime, is left, whichever is shorter. Before, a token that lived an hour or less read as due the moment it arrived, so every command rotated the refresh token and then called the cloud unsigned. Any unexpired token is now sent with the request, except the refresh call, which carries none. `akan login` signs in again when a session is due for refresh and has no refresh token left.
|
|
140
|
-
- c3b5b3c:
|
|
141
|
+
- c3b5b3c: The framework no longer puts `@akanjs/native/core` on a web page. `isNativeApp()` and the other host checks read the platform the shell writes before the bundle runs, and a native shell still starts core with the page. A file that imports `akanjs/client/native` statically still brings core to every page that reaches it. On the minimal app this takes 15 KB raw (5.6 KB brotli) off the vendor floor and 20 KB raw (6.6 KB brotli) off every route entry. `akan build` also resolves the `production` export condition for the browser, SSR-client, CSR and MCP App view bundles, so a package such as Lexical ships its production build alone. On the minimal app the Agent editor chunk drops from 91 KB to 63 KB brotli. `akan start` is unchanged.
|
|
141
142
|
- 6d5b544: A database service now has `create<Model>Many(rows)`. It creates the rows on the same path as `create<Model>`: `_preCreate` and `_postCreate`, the document's save and create hooks, and the live and pubsub events each row's commit fires. Every row's `_preCreate` and document pre hooks run first, so a check there that queries the table does not see the batch's own rows. The rows are then written in one transaction, joining the caller's when one is open, with one multi-row INSERT per 500 rows instead of one statement per row; if the database refuses one row, none is written. Every row's `_postCreate` runs once the transaction commits, as `create<Model>`'s runs once its row is stored, so a `_postCreate` that throws leaves the rows written. A service that already declares a method of that name, itself or in a lib service it extends, keeps its own at runtime, but its signature no longer typechecks against the generated one: rename it.
|
|
142
143
|
|
|
143
144
|
A store can list actions in `static silent = ["refreshToken"] satisfies (keyof UserStore)[]`. When a silent action fails, it shows no toast and logs at verbose instead of error. It still rejects with the original error, so a background write such as a token refresh is awaited by its caller, which handles the failure. Generated actions can be listed, and a store built on a lib store keeps the lib's silent actions. A name in `static silent` that is not an action of the store is reported when the store registers.
|
|
@@ -165,7 +166,7 @@
|
|
|
165
166
|
- **`@akanjs/devkit`**: `applyTemplate` takes `dryRun` to list the files a template would write without writing them, and `WorkspaceExecutor.commit` takes an `identity` to commit under.
|
|
166
167
|
|
|
167
168
|
- 1870fcd: `akan verify` adds a `fix:` line to a module file's two-parent import that names the layer barrel to import from instead, such as `import from "@apps/<app>/common"`. The lint message itself now points at the layer barrel too.
|
|
168
|
-
- 76ab8d4: `akan doctor` exits with code 1 when its report says `"status": "failed"`, in text or JSON, with or without `--strict`, and for `--ios`, so the `agent:doctor` script (`akan doctor --strict --format json`) or a CI step fails on it. Before, it always exited 0. The full report still prints on stdout. A warning never changes the exit code: `--strict` still only decides which conventions count as errors, today a missing module abstract. Doctor's `dictionary-label-missing` now reads a constant's fields
|
|
169
|
+
- 76ab8d4: `akan doctor` exits with code 1 when its report says `"status": "failed"`, in text or JSON, with or without `--strict`, and for `--ios`, so the `agent:doctor` script (`akan doctor --strict --format json`) or a CI step fails on it. Before, it always exited 0. The full report still prints on stdout. A warning never changes the exit code: `--strict` still only decides which conventions count as errors, today a missing module abstract. Doctor's `dictionary-label-missing` now reads both a constant's fields and its dictionary's labels from their syntax, ignoring comments and strings. A field that appears only in a comment no longer asks for a label: a new `--sample` workspace no longer warns about `task`'s `priority`, `tags`, `assignee` and `completedAt` or `workHistory`'s `actor` and `previousStatus`, which its constants suggest only in comments. A label that exists only in a comment, or a `name:` inside a string, no longer counts, so a field whose label is commented out now warns.
|
|
169
170
|
- 245d318: A font declared on a root layout now gets its metric-adjusted fallback faces: `@font-face` rules named `"<font> fallback"` that draw a local system font with `size-adjust`, `ascent-override` and `descent-override` matched to the web font. The variable `--font-<name>` already named that family, but the build never wrote it, because fontaine's `readMetrics` takes a URL and returned nothing for the file path it was given. While a web font loaded (`font-display: swap`), text drew in the browser's default font, usually a serif, and moved when the web font arrived. The metrics are read from the source font, not the subset, so a subset without Latin letters (`subsets: ["ks-x-1001"]`, or only `subsetText`) still sizes the fallback correctly. A font whose metrics cannot be read keeps its face and gets no fallback, as before. Set `adjustFontFallback: false` on a font to keep the old output. Cached font output from `akan start` is regenerated once.
|
|
170
171
|
- 6f6cc2a: A fresh clone of a workspace now builds and starts after `bun install` alone. The env files that hold secrets stay gitignored, so a clone has none of them: before, `akan typecheck`, `akan build` and `akan verify` failed with `Cannot find module './env.server.local'`, and with no `.env` the CLI picked the `debug` environment, so `akan build` stopped asking for `AKAN_PUBLIC_REPO_NAME` and `AKAN_PUBLIC_SERVE_DOMAIN`.
|
|
171
172
|
|
|
@@ -203,7 +204,8 @@
|
|
|
203
204
|
- **A narrowed build fails when route code imports a name its facet does not export**, and names the missing names. Before, the page threw a `SyntaxError` in the browser.
|
|
204
205
|
|
|
205
206
|
- 4561152: The skill examples now use one neutral domain, books from a public read-only API: a `BookApi` adapter in `srvkit/`, a `lib/_openlibrary/` service with a `book` query, `fetch.book(id)` and `/books/[id]` pages. This covers the `akan-service` external-API recipe, the `akan-page` API summary, the `akanApi` import block, the `config` rule's `localePrefix` example and the README's as-needed path. The recipes teach the same steps as before.
|
|
206
|
-
- 53eb6f1: A command whose app-or-lib target is left out no longer stops at a prompt error when stdin is not a terminal, as in a coding agent's shell or CI. In a workspace with a single app, lib or package, `akan sync`, `akan lint`, `akan test` and `akan create-module <name>` run on it and say so on stderr, so a new workspace's `bun run sync`, `bun run lint` and `bun run test` work there. Otherwise every target command (app, lib, package, module) exits at once with the valid targets and the line to rerun, such as `akan sync <app-or-lib>`. A
|
|
207
|
+
- 53eb6f1: A command whose app-or-lib target is left out no longer stops at a prompt error when stdin is not a terminal, as in a coding agent's shell or CI. In a workspace with a single app, lib or package, `akan sync`, `akan lint`, `akan test` and `akan create-module <name>` run on it and say so on stderr, so a new workspace's `bun run sync`, `bun run lint` and `bun run test` work there. Otherwise every target command (app, lib, package, module) exits at once with the valid targets and the line to rerun, such as `akan sync <app-or-lib>`. A terminal is asked as before.
|
|
208
|
+
- 44149b1: Without a terminal, a command that deletes nothing now takes the workspace's only library, package or module when none is named, as it already did for the only app or lib, and names it on stderr: `No lib named, so this runs on 'util', the only one in this workspace: akan sync-library util`. This covers `sync-library`, `sync-package`, `build-package`, `verify-dist-package`, `create-view`, `create-unit`, `create-template` and `create-crud-page`. Before, each refused and asked for the target to be named. The remove commands still never take one they were not given. A terminal is still asked, even when there is only one choice.
|
|
207
209
|
- b24fef3: An app that draws no Akan UI component now builds and runs without the UI's optional peers. Before, `akan build` failed with `Could not resolve: "react-icons/ai"` or `"react-spring"` in an app that imported nothing from `akanjs/ui`: the System UI every root layout mounts drew react-icons glyphs and reached the model, list and dialog components through the `Load` and `Button` modules, and the CSR build, on by default, bundled the whole `akanjs/ui` barrel.
|
|
208
210
|
|
|
209
211
|
**Upgrade note.** A workspace can drop `react-spring`, `@react-spring/web`, `@use-gesture/react`, `react-refresh`, `scheduler` and `tailwind-scrollbar` from its `package.json`, unless its own code imports them. `akanjs` now depends on `@react-spring/web`, `@use-gesture/react`, `react-refresh`, `scheduler` and `tailwind-scrollbar` itself, because the System UI, the dev runtime and `akanjs/ui/styles.css` always use them, and nothing uses `react-spring` any more. New workspaces no longer list them. A workspace whose own code keeps `react-spring` or `@react-spring/web` must move it to major 10: an older one installs a second copy under `akanjs`, and a spring value the app makes no longer typechecks on an `animated.*` element from `akanjs/ui`.
|
|
@@ -215,28 +217,60 @@
|
|
|
215
217
|
- **The CSR build and its MCP App views** rewrite `akanjs/ui` imports to the components a file names, as the SSR route builds already did, so a page that imports `Link` no longer bundles every component.
|
|
216
218
|
- **Animations use `@react-spring/web`** instead of the `react-spring` umbrella package, which also installed the native, three.js, konva and zdog renderers.
|
|
217
219
|
|
|
220
|
+
- b516497: `akan create-package --name` takes `name` or `@scope/name`, each part lowercase letters, digits, `-`, `.` and `_` starting with a letter or digit, and refuses anything else before it touches a file: `"../x" is no package name; use name or @scope/name, each of lowercase letters, digits, -, . and _, starting with a letter or digit`. Before, `--name ../x` wrote `x/` beside `pkgs/` and added it to the root `tsconfig.json`. `akan create-ui`, `add-field` and `add-enum-field` refuse a `--module` that is not a module name with a `primitive-input-invalid` diagnostic in their report, as they already reported a missing one. Before, `create-ui --module ../x` wrote its component into the app root, and `add-field --module ../x` looked for the constant and dictionary outside the module folder. `plainNameProblem` returns the message `assertPlainName` throws, and `assertPackageName` sits beside it in `@akanjs/devkit/workflow/plainName`.
|
|
218
221
|
- 58b7329: `akan create-scalar`, `remove-scalar`, `create-module`, `create-service`, `create-application`, `create-library`, `install-library` and `create-workspace --app` refuse a name that is not one plain path segment before they touch a file, such as `"../.." is no scalar name; use letters, digits, - and _, starting with a letter`. Before, the name went into the path unchecked: `akan remove-scalar ../.. <app>` deleted `apps/<app>` and exited 0, and the create commands wrote their files outside `lib/`, `apps/` or `libs/`. A scalar, module or service name is checked as a workflow's `module` input already was, an app or lib name as its `app` input; both checks now live in `@akanjs/devkit/workflow/plainName`, with `assertPlainName`.
|
|
222
|
+
- 4e0a38e: A page preview starts a new browser when the one before it died. Bun refuses to start a new browser until a later event-loop turn has reaped the dead one. `akan preview` opens its pages back to back when the app's log socket does not answer. In that case, if Chrome died during one page, every page after it failed at once with `Failed to spawn Chrome (set BUN_CHROME_PATH, …)`. Now a preview whose browser fails to start for any reason other than running out of `startTimeoutMs` waits 100 ms and tries the start once more. A browser that cannot start at all still fails in about 100 ms, now with its own error, such as `Chrome process closed the pipe`.
|
|
219
223
|
- b4b8f85: `akan preview` and the code agent's page preview give a browser that died or was closed the full `startTimeoutMs` (120 s by default) for its next start. Before, after one page had run out of the start bound, a browser that then died while another page waited for it stayed marked as slow to start. The next page started a fresh browser within only its own `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
|
|
220
224
|
- 686e0b8: `akan preview` and the code agent's page preview bound a page only once the browser is up. Before, a process's first page shared its `timeoutMs` with starting the browser. On a cold machine where Chrome took longer than that to start, the first page failed as `Timed out loading …; the dev server may be rebuilding or stuck.`
|
|
221
225
|
|
|
222
226
|
Starting the browser now has its own bound of 120 s, `startTimeoutMs` on `PreviewView.probe`. Running out of it reads `Timed out starting the <engine> browser after 120 s.`, where the engine is `chrome` or `webkit`. A browser that misses its bound may still come up late, so later pages in that process try again, but each waits only its own `timeoutMs` for the browser. Once the browser answers, the full bound applies again. So a browser that never starts holds the first page for 120 s, where before it held it for `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
|
|
223
227
|
|
|
224
|
-
- eefd9cb: `akan preview <app> /_akan/studio` opens Studio instead of a 404. Preview added the default locale to every path, and the server routes `/_akan/*` without one, so `/en/_akan/studio` matched no page. A path under `/_akan`, `/__csr`, `/__rsc`, `/.well-known` or the app's API prefix, a file the app's `public/` holds, and the server's own `/favicon.ico`, `/robots.txt`,
|
|
228
|
+
- eefd9cb: `akan preview <app> /_akan/studio` opens Studio instead of a 404. Preview added the default locale to every path, and the server routes `/_akan/*` without one, so `/en/_akan/studio` matched no page. A path under `/_akan`, `/__csr`, `/__rsc`, `/.well-known` or the app's API prefix, a file the app's `public/` holds, and the server's own `/favicon.ico`, `/robots.txt`, `/sitemap.xml` (at the root only, the one place the server answers it) and `manifest.webmanifest` now open as written: no locale, and no CSR boot query under `--csr`. Every other path, including one that already starts with a locale or ends in a dotted segment such as `/release/1.2.3`, is a page and gets the default locale as before. The `akan-debug` skill and the `akan preview` reference say so.
|
|
229
|
+
- 01ca04b: `akan preview` opens `/openapi.json` and the default `/mcp` as written, as the server mounts them outside `/:lang`; before, they got the default locale and opened a 404 page. A full URL (`http://localhost:<port>/oauth/token`) now opens exactly as written, with no locale and no CSR boot query, so an endpoint mounted outside the API prefix or a custom MCP path can be opened; before, a full URL was taken by its path and given the locale. A page `--changed` finds always gets the locale, even one at a server route's path such as `/mcp`. A screenshot's file name carries the page's query and a short hash of it, and a long path or query is cut with a hash, so `/_akan/studio?view=runtime` and `?view=signals` no longer overwrite one another and a long query no longer fails the page. The `akan-debug` skill, `akan preview --help` and the `akan preview` reference state the same rule.
|
|
225
230
|
- b101fcf: `akan remove-application`, `remove-library`, `remove-package`, `remove-module` and `remove-scalar` no longer delete a target they picked themselves. Left out, or named but not in the workspace, the target is refused in a terminal or not, with the choices and the line to rerun, such as `` Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-application <app>` (choices: pokedex). `` Before, `akan remove-application` in a one-app workspace removed `apps/<app>` without asking, and without a terminal `akan remove-scalar <name>` removed the scalar from the only app or lib. A terminal is no longer offered a select for these commands. Other commands still take the only app, or the only app or lib without a terminal, as before. A command declares this with `destructive: true` on its target.
|
|
231
|
+
- 44149b1: `akan remove-module` now removes a service module named exactly, as in `akan remove-module pokedex:_billing` for `apps/pokedex/lib/_billing`. Before, no command could remove a service module: `remove-module` left out every `lib/_<name>` folder and refused one by name with `Invalid module name: _billing`. A service module left out or misnamed gets the same refusal as any target of a remove command, and its choices now list the service modules. `create-view`, `create-unit`, `create-template` and `create-crud-page` still take only a model module. A command opts in to service modules by taking `ModuleOrService` instead of `Module`.
|
|
226
232
|
- 4fcf44f: A new `rename-key` workflow renames a slice key or a Filter query key everywhere it is spelled. That covers the key itself, every name derived from it (`initMemoInPublic`, `memoListInPublic`, `queryByName`) in the app and every lib, and its module dictionary entry. It is the same rename the editor extension's F2 runs, so agents get it through `plan_workflow` / `apply_workflow` and the CLI through `akan workflow plan rename-key --app <app> --module <module> --slice <key> --to <newKey>` (or `--query <key>`). The plan lists each file it will change. A use that could also be another model's is left alone, and `akan verify` then reports it.
|
|
233
|
+
- 627dde4: The route JS budget stops failing deploy builds: a plain `akan build` reports an overage, `akan build --check-budget` fails on it, and CI runs that for `apps/minimal` and `apps/akan`, which now commit their `akan.budget.json`. The rc npm publish, which calls CI, skips that job, so an app's budget never holds a release.
|
|
234
|
+
|
|
235
|
+
- **Report by default:** a plain `akan build`, the one a deploy runs, prints a route over its entry or with none, or an unreadable `akan.budget.json`, as a warning under the route table and passes, as `akan verify` does. `--quiet` drops the rest of the summary but still prints the warning.
|
|
236
|
+
- **`--check-budget`** fails the build on an overage, a route with no entry (a new page records its entry with `--update-budget` in the same change), an unreadable file, or an app with no file, naming every overage and missing entry in one failure.
|
|
237
|
+
- **`--raise-budget`** re-records every route at its measured size and names the entries it raised, for the change that grows a route on purpose. An overage now names it with the app's own name, along with the file and section to edit by hand. `--check-budget`, `--update-budget` and `--raise-budget` exclude each other.
|
|
238
|
+
- **`System` counts as the SSR shell:** the generated root layout imports `System` whole because it serves SSR pages, the CSR shell and MCP App views. The measure now reads that import as `System/SSR.tsx` and leaves out `System`'s CSR and MCP App providers wherever an app imports `System`, since no server page renders them; every client entry is still built. On `apps/minimal` the median route drops from 342KB to 307.5KB brotli and the largest, `/:lang/explore`, from 499.1KB to 473.3KB. On `apps/akan` most routes drop about 1KB, because their other client entries load the providers' dependencies anyway.
|
|
239
|
+
|
|
227
240
|
- 9a9e1fa: A server page from `akan build` now preloads its client modules from `<head>`, so a cold load stops waiting on chains of imports.
|
|
228
241
|
|
|
229
242
|
- **What is preloaded:** the rsc client and the chunks it imports, the chunks every client reference row (`I` row) in the shell's flight names, and the importmap vendors those chunks import, each with its own chunks. A client URL that only the page's own text carries is never preloaded. Before, only the rsc client and the locale dictionary were preloaded. The browser found the rest one import level at a time, after the document finished.
|
|
230
|
-
- **Measured on `apps/minimal`:** `/en/explore` preloads
|
|
243
|
+
- **Measured on `apps/minimal`:** `/en/explore` preloads 64 modules and its flight names 37 client chunks, all of them preloaded. `/en/lab/buttons` preloads 165 for 137. Preloading every client component each route's page and layouts import would have meant 274 modules for `/en/explore`, so the preloads follow the flight instead.
|
|
231
244
|
- **A boundary that resolves after the shell** still loads its chunks when its flight rows arrive. `akan start` preloads nothing new, because a dev page loads its client code from the SSR dev registry.
|
|
232
245
|
- `routes-manifest.json` carries the preload table as `modulePreloads`: the runtime chunks, each vendor's chunks, and the vendors each browser chunk imports.
|
|
233
246
|
|
|
234
|
-
- 9a9e1fa: `akan build` measures the JavaScript each server route loads, and
|
|
235
|
-
|
|
236
|
-
- **The measure:** the runtime, every client module statically reachable from the route's page and layouts, and their vendors, counted in the brotli bytes the server sends. A chunk loaded by a dynamic `import()` is left out, and so is the locale dictionary. A new `Measuring route JS` step names the largest route, and the summary lists the ten largest with their brotli and raw sizes. On `apps/minimal` the largest is `/:lang/explore` at
|
|
237
|
-
- **The budget:** `akan build <app> --update-budget` writes `apps/<app>/akan.budget.json` with the environment it built for and one `routeJsBrotliKB` entry per route. After that,
|
|
238
|
-
- **Where it fails:** `akan build
|
|
239
|
-
- **Opt-in:** an app without the file gets the report and nothing fails. A route without an entry is reported,
|
|
247
|
+
- 9a9e1fa: `akan build` measures the JavaScript each server route loads, and reports a route that outgrows the budget the app commits; `akan build --check-budget` fails on it.
|
|
248
|
+
|
|
249
|
+
- **The measure:** the runtime, every client module statically reachable from the route's page and layouts, and their vendors, counted in the brotli bytes the server sends. A chunk loaded by a dynamic `import()` is left out, and so is the locale dictionary. A new `Measuring route JS` step names the largest route, and the summary lists the ten largest with their brotli and raw sizes. On `apps/minimal` the largest is `/:lang/explore` at 473.3KB brotli (1.2MB raw).
|
|
250
|
+
- **The budget:** `akan build <app> --update-budget` writes `apps/<app>/akan.budget.json` with the environment it built for and one `routeJsBrotliKB` entry per route. After that, a route that passes its entry by more than the larger of 1% and 4KB, as the bundle size gate allows, is an overage, named with its bytes and its limit. The slack absorbs the `AKAN_PUBLIC_*` values a build inlines, so a budget recorded for one environment holds for another, and an overage says when the file was recorded for another one. The update adds a new route, lowers an entry its route now fits under and drops a route that is gone. It never raises an entry: `--raise-budget` or a hand edit does, in the change that grows the route.
|
|
251
|
+
- **Where it fails:** only `akan build --check-budget`, on an overage, a route with no entry, an unreadable file or a missing one. A plain `akan build` warns of each but a missing file under its route table, and `--update-budget` rewrites an unreadable file. `akan verify` warns of each route over its entry or with none and of an unreadable file, one line each, but never fails on them. A mobile or desktop build skips the step.
|
|
252
|
+
- **Opt-in:** an app without the file gets the report and nothing fails, unless `--check-budget` asks for it. A route without an entry is reported, and fails only `--check-budget`. `akan sync` accepts `akan.budget.json` at an app root.
|
|
253
|
+
|
|
254
|
+
- 9055634: Partial RSC commit can now patch a real page, and it patches cleanly. It is still off by default behind `AKAN_PUBLIC_RSC_PARTIAL_COMMIT=1`. With the flag on:
|
|
255
|
+
|
|
256
|
+
- **Which pages render their head through `AkanRouteHead`.** Every page whose head is plain `title`, `meta` and `link` tags now renders it through a client component instead of plain head tags. That way any page can be the one a patch starts from. Patches still only land on pages that set `.config({ rscPatchHeadSafe: true })`.
|
|
257
|
+
- **A plain `.head()` is its own head snapshot.** A page no longer needs a hand-written snapshot. A head with anything else, such as a script or a component, keeps the full render.
|
|
258
|
+
- **React renders the head.** The old patch swapped `<head>` nodes behind React's back. That left a stale or broken head after the next full navigation or a back navigation.
|
|
259
|
+
- **A patch carries only the page's part of the head.** The layouts' part stays on the client, under the layouts the patch keeps. The page's part travels in the `X-Akan-Rsc-Patch-Head-Snapshot` header, which is now capped at 2 KB encoded, down from 12 KB. A larger page head renders whole, and the server logs that once per route.
|
|
260
|
+
- The cap leaves room under nginx's default 4 KB `proxy_buffer_size`, but it doesn't guarantee a fit: router state, trace headers and `Set-Cookie` share that buffer.
|
|
261
|
+
- The docs pages' part is about 1.1 KB, against about 5.7 KB for their whole head.
|
|
262
|
+
- **The layouts a patch keeps follow the new page.** `usePathCtx()` under an SSR page gives the patched page's path, pathname and query, so a menu's active item and a pager move with it.
|
|
263
|
+
- **A full commit after a patch goes straight to the new page's head, path and body.** It no longer shows the last full page for a frame first.
|
|
264
|
+
- **A patch falls back to a full render when the kept layouts would be wrong:**
|
|
265
|
+
- another route group's layout sits at the same URL prefix (`(guest)/account/login` to `(member)/account/settings`);
|
|
266
|
+
- the layouts' head would be different for the target, for example a layout `.head()` that reads a search arg. The router state carries a key of the layouts head its page rendered (`headKey`), and the server compares it with the target's. The key changes together with the rest of the router state, so a navigation started while the previous page is still committing sends that page's key;
|
|
267
|
+
- a route param differs (`/en` to `/ko`);
|
|
268
|
+
- a frame setting differs, such as `transition`;
|
|
269
|
+
- the page navigated from has no patchable head;
|
|
270
|
+
- its frame cannot be resolved.
|
|
271
|
+
- **A client built without the flag no longer asks for patches it cannot apply.** Before, a server running with the flag answered such a client with patches, and every sibling navigation made a second request. Such a client leaves the head key out of the router state it sends.
|
|
272
|
+
- **A refused head-safe patch runs each `.head()` once.** When a patch is refused after its head resolved, the full render reuses that head. Before, it ran every `.head()` a second time.
|
|
273
|
+
- **`@akanjs/devkit`: the SSR runtime manifest keeps every client export of the segment outlet module.** Before, it kept only one.
|
|
240
274
|
|
|
241
275
|
- 27440aa: A production build now runs without Docker: `cd dist/apps/<app> && bun main.js` serves the app with no env set. Until now the env the built server needs (the app and repo names, the serve domain, locales, route prefixes, database modes and the rest) existed only as `ENV` lines in the generated Dockerfile. On a host without the image, such as a PaaS or a test harness, the server stopped at boot with `getEnv() cannot run at build time` unless a start script copied every value by hand.
|
|
242
276
|
|
|
@@ -293,6 +327,14 @@
|
|
|
293
327
|
- **A public env value holding a private key is refused** when the native build records its `AKAN_PUBLIC_*` values, as names with `SECRET`, `TOKEN`, `PASSWORD`, `PRIVATE` or `CREDENTIAL` already were.
|
|
294
328
|
- **akan-native only warns about `'unsafe-inline'` in `script-src`.** Inline styles under `style-src 'unsafe-inline'` no longer produce a warning; inline scripts are still left unhashed when `script-src` allows them.
|
|
295
329
|
|
|
330
|
+
- db5b284: An app using the `shared` and `util` libraries no longer loads `@akanjs/native/core` on its web pages. Their stores imported `akanjs/client/native` for `isNativeShell()` and for the push installation id. That put core and every native plugin wrapper on every page. Native SSO and `usePushNotification` now load the plugin they call on first use inside a shell. The `useCamera`, `useGeoLocation` and `usePurchase` hooks in `akanjs/webkit`, which `shared`'s `Field` and `User.Template` reach, import their plugin and the few core exports they use dynamically, when first called; inside a shell `useCamera` still reads the camera permission at mount. On the minimal app with `shared` merged into its store:
|
|
331
|
+
|
|
332
|
+
- a Link route drops from 148.7 KB raw (38.5 KB brotli) to 101.5 KB raw (23.3 KB brotli);
|
|
333
|
+
- a route drawing `Field` drops from 416.6 KB (113.8 KB brotli) to 370.2 KB (99.8 KB brotli);
|
|
334
|
+
- a route drawing `User.Template` drops from 526.8 KB (140.2 KB brotli) to 481.2 KB (126.3 KB brotli).
|
|
335
|
+
|
|
336
|
+
The runtime guideline and the `no-web-only-api-outside-webkit` message now name `akanjs/client` for `isNativeApp()`, and the native subpaths for push and haptics.
|
|
337
|
+
|
|
296
338
|
- 5e08722: A coding agent now finds the imports and signatures a first app needs in its skills, instead of reading the akanjs sources.
|
|
297
339
|
|
|
298
340
|
- **The `akan-service` and `akan-page` recipes** start each file with its import lines, and the `akan-service` rules say what `serverMode: "batch"` does: the module, endpoints included, mounts only in a `batch` or `all` process, so a page-facing service leaves it off.
|
|
@@ -321,6 +363,20 @@
|
|
|
321
363
|
- A UI file that reads another UI file's export at its top level, through an import cycle, can now find it uninitialised and throw a TDZ error. Move that read into the component.
|
|
322
364
|
- Generated module `index.ts` files only collect namespaces, so they are unaffected.
|
|
323
365
|
|
|
366
|
+
- 94d3673: Akan Studio lands on the Runtime view when `akan start`'s builder is stopped, not only when there is none or it never answers.
|
|
367
|
+
|
|
368
|
+
- **The dev host marks a reply no builder answered.** A builder request the dev host answers itself carries `kind: "unavailable"`: the builder is stopped, not started yet, failed to start, or exited or was stopped before answering. A failure the builder reports, such as a broken build, carries no kind and reads as before.
|
|
369
|
+
- **`BuilderRpc.isUnavailable(error)`** tells such a failure apart, as `BuilderRpc.isTimeout` does for a request that times out. A bare, empty or unknown `/_akan/studio` view then lands on Runtime, and a view asked for by name still answers 503 with the reason.
|
|
370
|
+
- **Mixed versions keep working.** A server reading a reply from an older dev host, with no kind, answers 503 as before, and an older server ignores the new field.
|
|
371
|
+
|
|
372
|
+
- 44149b1: A module named in a library that lacks it, such as `akan create-view util:nope`, is refused at once, as one named in an app is. Before, it fell through to the module picker in a terminal, and without one it failed as if no module had been named. Both are now refused, terminal or not, with the modules there are and the line to rerun: `` Unknown module 'util:nope': util has no such module, so rerun it as `akan create-view <sys:module>` (choices: pokedex:card, util:photo). `` An app's used to say only `Invalid module name: nope`.
|
|
373
|
+
- 0d7294c: Narrowed vendor entries hold up during a rolling deploy and on very large builds.
|
|
374
|
+
|
|
375
|
+
- **A route chunk that fails to load during a rolling deploy now loads its page as a document.** A document from the new build can get a page from a replica the deploy has not reached yet, and that page's chunks can import a name the document's narrowed vendor entries lack. Before, the page broke, and React's Flight client kept the failed load, so retrying in the tab failed the same way. Now, if a navigation to another page has not committed, the tab loads its target as a document, which brings a matching import map. If the page is already on screen (a refresh of it counts), the tab reloads it at its current address, fragment and any later query-only change included, and only when the error is a module link or resolve error and the browser is online. A network, ad-blocker or evaluation error leaves the page in place. Only chunks that the latest page navigation's response imports count. The document's own chunks, an overtaken navigation's and a replaced response's do not trigger it. The exception is a refresh of the same page that imports a chunk the document is still loading, which takes that chunk over. Each chunk starts at most one document load per tab, so a chunk that always fails shows its error instead of reloading in a loop. While one document load is under way, another failure does not start a second; the page's next navigation, or its return from the back-forward cache, ends that wait. This applies to `akan build` output only.
|
|
376
|
+
- **A document fallback to the address the tab already shows now reloads it.** A failed refresh, or a back or forward move to a URL with a `#fragment`, fell back with `location.replace` to that same URL, which only scrolls to the fragment and loads nothing. It now calls `location.reload()`.
|
|
377
|
+
- **`akan build` fails when the vendor scan cannot find a route's client entry**, and names the entry. Before, a build that wrote its route bundles somewhere the scan did not look narrowed every vendor entry to nothing. The build passed, and pages threw in the browser.
|
|
378
|
+
- **The vendor scan no longer overflows the stack on large minified chunks.** A function body of about 20,000 statements, which a minifier folds into one comma chain, used to fail `akan build` with `Maximum call stack size exceeded`.
|
|
379
|
+
|
|
324
380
|
- bd4d45a: `akan verify` now fixes or explains three lint failures that were costly to resolve by hand.
|
|
325
381
|
|
|
326
382
|
- **A daisyUI component class your own CSS defines is no longer refused.**
|
|
@@ -337,6 +393,7 @@
|
|
|
337
393
|
- d2b2291: A failed workflow no longer reports success to its caller. `akan workflow plan` exits 1 when the plan has an error, `akan workflow apply` when the apply or its `--dry-run` fails, and `akan workflow validate` when its status is `failed` (which includes `passed-with-baseline-blockers`); all three exited 0 before. A dry run of a plan with an error, such as `add-field --type Number`, reports `failed` and lists no changed or generated files, where it used to list the plan's edits under "Automatically Modified". Over MCP, `plan_workflow` names `apply_workflow` in `next` and `approval.canApplyWith` only for a plan without an error, and `apply_workflow` names `run_validation` only when the apply did not fail; a refused plan also drops its "apply this plan" and "validate the apply report" recommendations. The workflows that write into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`) now refuse a service module, or any folder without its `<module>.constant.ts`, at plan time and again at apply. The `workflow-module-not-database` error lists the target's database modules and points at `create-module`, and for a field at `create-scalar`. Before, `add-field` on a service module passed its plan and dry run and failed only at apply, and `create-ui` wrote a misnamed `_<service>.View.tsx` into it. The `workflow-module-missing` error now lists only database modules too, says a name that matches a service module (with or without its `_`) is one instead of asking "Did you mean \_<service>?", and points at another app or library only when it holds a database module of that name.
|
|
338
394
|
- adcdf3b: `akan workflow plan` and the `plan_workflow` MCP tool refuse a module the target app or library lacks, for every workflow that writes into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`). The `workflow-module-missing` error names the module, lists the modules the target has, and points at the module whose name differs only in case or by a leading `_`, at a scalar of that name, or at the app or library that holds it; only when none exists does it point at `create-module`. Before, the plan passed and its dry run reported `passed`. `add-mutation` used to plan a missing module with a warning so that a `create-module` plan could precede it; it now refuses too, so plan it after applying `create-module`. Every workflow now refuses an unknown `app` at plan time, listing the workspace's apps and libraries. `apply` and `--dry-run` check the target again before anything else, so a plan written earlier for a module that is gone fails instead of passing. `plan_workflow` now inspects the MCP server's workspace rather than the process directory.
|
|
339
395
|
- Updated dependencies [bc792ab]
|
|
396
|
+
- Updated dependencies [31f7039]
|
|
340
397
|
- Updated dependencies [61fbd7d]
|
|
341
398
|
- Updated dependencies [b3f85fa]
|
|
342
399
|
- Updated dependencies [a707354]
|
|
@@ -358,11 +415,13 @@
|
|
|
358
415
|
- Updated dependencies [1ebc57a]
|
|
359
416
|
- Updated dependencies [c3b5b3c]
|
|
360
417
|
- Updated dependencies [6d5b544]
|
|
418
|
+
- Updated dependencies [73f8830]
|
|
361
419
|
- Updated dependencies [958f175]
|
|
362
420
|
- Updated dependencies [9f30e93]
|
|
363
421
|
- Updated dependencies [d9eaacc]
|
|
364
422
|
- Updated dependencies [d9eaacc]
|
|
365
423
|
- Updated dependencies [c334caf]
|
|
424
|
+
- Updated dependencies [170be15]
|
|
366
425
|
- Updated dependencies [e143263]
|
|
367
426
|
- Updated dependencies [e4f7876]
|
|
368
427
|
- Updated dependencies [cb0fd5b]
|
|
@@ -376,11 +435,15 @@
|
|
|
376
435
|
- Updated dependencies [4561152]
|
|
377
436
|
- Updated dependencies [73e833b]
|
|
378
437
|
- Updated dependencies [b24fef3]
|
|
438
|
+
- Updated dependencies [465ccb9]
|
|
439
|
+
- Updated dependencies [513e48d]
|
|
379
440
|
- Updated dependencies [5cd1fc3]
|
|
380
441
|
- Updated dependencies [f47cc82]
|
|
442
|
+
- Updated dependencies [7f0de74]
|
|
381
443
|
- Updated dependencies [1f93507]
|
|
382
444
|
- Updated dependencies [5d3cb3e]
|
|
383
445
|
- Updated dependencies [9a9e1fa]
|
|
446
|
+
- Updated dependencies [9055634]
|
|
384
447
|
- Updated dependencies [3537448]
|
|
385
448
|
- Updated dependencies [6c3a303]
|
|
386
449
|
- Updated dependencies [27440aa]
|
|
@@ -392,14 +455,20 @@
|
|
|
392
455
|
- Updated dependencies [e3de9ef]
|
|
393
456
|
- Updated dependencies [e3de9ef]
|
|
394
457
|
- Updated dependencies [99fda20]
|
|
458
|
+
- Updated dependencies [db5b284]
|
|
395
459
|
- Updated dependencies [5e08722]
|
|
396
460
|
- Updated dependencies [df32c46]
|
|
397
461
|
- Updated dependencies [5f809e8]
|
|
398
462
|
- Updated dependencies [76705dd]
|
|
399
463
|
- Updated dependencies [206bb69]
|
|
464
|
+
- Updated dependencies [170be15]
|
|
465
|
+
- Updated dependencies [cfe50cb]
|
|
466
|
+
- Updated dependencies [94d3673]
|
|
400
467
|
- Updated dependencies [e4c7e5c]
|
|
401
468
|
- Updated dependencies [6291fec]
|
|
402
469
|
- Updated dependencies [0c480c6]
|
|
470
|
+
- Updated dependencies [e4c3009]
|
|
471
|
+
- Updated dependencies [0d7294c]
|
|
403
472
|
- Updated dependencies [d9eaacc]
|
|
404
473
|
- akanjs@3.2.0
|
|
405
474
|
- @akanjs/language@3.2.0
|
|
@@ -143,3 +143,54 @@ describe("a gateway whose replica crash-loops", () => {
|
|
|
143
143
|
]);
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
describe("a builder request while no builder is running", () => {
|
|
148
|
+
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
|
|
149
|
+
const track = tempRoots();
|
|
150
|
+
const stoppedBuilder = {
|
|
151
|
+
status: "stopped",
|
|
152
|
+
patcherOff: false,
|
|
153
|
+
start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
|
|
154
|
+
send: () => false,
|
|
155
|
+
stop: () => undefined,
|
|
156
|
+
} as unknown as IncrementalBuilderHost;
|
|
157
|
+
|
|
158
|
+
test("is answered at once and marked unavailable, so the backend can tell it from a failed build", async () => {
|
|
159
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
160
|
+
const replyPath = path.join(root, "reply.json");
|
|
161
|
+
await writeText(
|
|
162
|
+
path.join(root, "apps/demo/main.ts"),
|
|
163
|
+
[
|
|
164
|
+
'import fs from "node:fs";',
|
|
165
|
+
`const replyPath = ${JSON.stringify(replyPath)};`,
|
|
166
|
+
'process.on("message", (message) => {',
|
|
167
|
+
' if (message?.type !== "build-graph-res") return;',
|
|
168
|
+
' fs.writeFileSync(replyPath + ".tmp", JSON.stringify(message));',
|
|
169
|
+
' fs.renameSync(replyPath + ".tmp", replyPath);',
|
|
170
|
+
"});",
|
|
171
|
+
'process.send?.({ type: "backend-ready", pid: process.pid });',
|
|
172
|
+
'process.send?.({ type: "build-graph", id: 7 });',
|
|
173
|
+
"setInterval(() => undefined, 1_000);",
|
|
174
|
+
"",
|
|
175
|
+
].join("\n"),
|
|
176
|
+
);
|
|
177
|
+
const create = IncrementalBuilderHost.create;
|
|
178
|
+
IncrementalBuilderHost.create = async () => stoppedBuilder;
|
|
179
|
+
const host = new AkanAppHost(app, { env: { ...process.env } as Record<string, string> });
|
|
180
|
+
try {
|
|
181
|
+
await host.start();
|
|
182
|
+
const deadline = performance.now() + 5_000;
|
|
183
|
+
while (!(await Bun.file(replyPath).exists()) && performance.now() < deadline) await Bun.sleep(25);
|
|
184
|
+
expect(await Bun.file(replyPath).json()).toEqual({
|
|
185
|
+
type: "build-graph-res",
|
|
186
|
+
id: 7,
|
|
187
|
+
ok: false,
|
|
188
|
+
error: "builder is stopped; reload after the builder is ready",
|
|
189
|
+
kind: "unavailable",
|
|
190
|
+
});
|
|
191
|
+
} finally {
|
|
192
|
+
await host.stop();
|
|
193
|
+
IncrementalBuilderHost.create = create;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -922,11 +922,11 @@ export class AkanAppHost {
|
|
|
922
922
|
}
|
|
923
923
|
#failBuilderRequest(message: BuilderMessage, error: string): boolean {
|
|
924
924
|
if (message.type === "build-route")
|
|
925
|
-
this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
|
|
925
|
+
this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error, kind: "unavailable" });
|
|
926
926
|
else if (message.type === "build-csr")
|
|
927
|
-
this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
|
|
927
|
+
this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error, kind: "unavailable" });
|
|
928
928
|
else if (message.type === "build-graph")
|
|
929
|
-
this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error });
|
|
929
|
+
this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error, kind: "unavailable" });
|
|
930
930
|
else return false;
|
|
931
931
|
return true;
|
|
932
932
|
}
|
package/akanContext.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { readdir } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
|
|
4
4
|
import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
|
|
5
|
-
import { constantFieldNames } from "./constantFieldNames";
|
|
6
5
|
import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
|
|
7
6
|
import { FileSys } from "./fileSys";
|
|
7
|
+
import { unlabeledFieldNames } from "./moduleFieldNames";
|
|
8
8
|
import { collectRecipeSources, findInlineRecipeDuplicates, scanRecipes } from "./recipeScanner";
|
|
9
9
|
import type { PackageJson } from "./types";
|
|
10
10
|
import {
|
|
@@ -851,8 +851,7 @@ export class AkanContextAnalyzer {
|
|
|
851
851
|
safeReadText(dictionaryPath),
|
|
852
852
|
]);
|
|
853
853
|
if (constantContent && dictionaryContent) {
|
|
854
|
-
for (const fieldName of
|
|
855
|
-
if (new RegExp(`\\b${fieldName}\\s*:`).test(dictionaryContent)) continue;
|
|
854
|
+
for (const fieldName of unlabeledFieldNames(constantContent, dictionaryContent)) {
|
|
856
855
|
report(
|
|
857
856
|
{
|
|
858
857
|
severity: "warning",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export type ApiPrintMode = "export" | "declare";
|
|
4
|
+
|
|
5
|
+
export class ApiDeclarationPrinter {
|
|
6
|
+
static readonly #reportedTags = ["deprecated", "internal"];
|
|
7
|
+
static readonly #printer = ts.createPrinter({ removeComments: true, newLine: ts.NewLineKind.LineFeed });
|
|
8
|
+
static readonly #headPattern =
|
|
9
|
+
"^((?:abstract |const )?(?:class|function|interface|type|enum|namespace|module|const|let|var) )";
|
|
10
|
+
|
|
11
|
+
static isNamed(
|
|
12
|
+
node: ts.Node,
|
|
13
|
+
): node is
|
|
14
|
+
| ts.ClassDeclaration
|
|
15
|
+
| ts.FunctionDeclaration
|
|
16
|
+
| ts.InterfaceDeclaration
|
|
17
|
+
| ts.TypeAliasDeclaration
|
|
18
|
+
| ts.EnumDeclaration
|
|
19
|
+
| ts.ModuleDeclaration {
|
|
20
|
+
return (
|
|
21
|
+
ts.isClassDeclaration(node) ||
|
|
22
|
+
ts.isFunctionDeclaration(node) ||
|
|
23
|
+
ts.isInterfaceDeclaration(node) ||
|
|
24
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
25
|
+
ts.isEnumDeclaration(node) ||
|
|
26
|
+
(ts.isModuleDeclaration(node) && ts.isIdentifier(node.name))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static prepared(statement: ts.Statement): ts.Statement {
|
|
31
|
+
const kept = (ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []).filter(
|
|
32
|
+
(modifier) =>
|
|
33
|
+
modifier.kind !== ts.SyntaxKind.ExportKeyword &&
|
|
34
|
+
modifier.kind !== ts.SyntaxKind.DefaultKeyword &&
|
|
35
|
+
modifier.kind !== ts.SyntaxKind.DeclareKeyword,
|
|
36
|
+
);
|
|
37
|
+
const stripped = ts.canHaveModifiers(statement)
|
|
38
|
+
? (ts.factory.replaceModifiers(statement as ts.HasModifiers, kept) as ts.Statement)
|
|
39
|
+
: statement;
|
|
40
|
+
return ts.isClassDeclaration(stripped) ? ApiDeclarationPrinter.#withoutPrivateMembers(stripped) : stripped;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static print(
|
|
44
|
+
statement: ts.Statement,
|
|
45
|
+
sourceFile: ts.SourceFile,
|
|
46
|
+
{ localName, name, mode }: { localName: string; name: string; mode: ApiPrintMode },
|
|
47
|
+
) {
|
|
48
|
+
const tags = ApiDeclarationPrinter.#tagsOf(ts.getOriginalNode(statement));
|
|
49
|
+
const node = ApiDeclarationPrinter.#withSortedUnions(ApiDeclarationPrinter.prepared(statement), sourceFile);
|
|
50
|
+
const printed = ApiDeclarationPrinter.#printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
|
|
51
|
+
const tagLine = tags.length ? `/** ${tags.map((tag) => `@${tag}`).join(" ")} */\n` : "";
|
|
52
|
+
if (mode === "export" && name === "default") return `${tagLine}export default ${printed}`;
|
|
53
|
+
const renamed =
|
|
54
|
+
localName === name
|
|
55
|
+
? printed
|
|
56
|
+
: printed.replace(
|
|
57
|
+
new RegExp(`${ApiDeclarationPrinter.#headPattern}${ApiDeclarationPrinter.#escape(localName)}(?![\\w$])`),
|
|
58
|
+
`$1${name}`,
|
|
59
|
+
);
|
|
60
|
+
const typeOnly = ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement);
|
|
61
|
+
const keyword = mode === "export" ? (typeOnly ? "export " : "export declare ") : typeOnly ? "" : "declare ";
|
|
62
|
+
return `${tagLine}${keyword}${renamed}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static printType(node: ts.TypeNode, sourceFile: ts.SourceFile) {
|
|
66
|
+
return ApiDeclarationPrinter.#printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//? A union's members print in the order the checker first met them, which moves when unrelated files change.
|
|
70
|
+
static #withSortedUnions(statement: ts.Statement, sourceFile: ts.SourceFile) {
|
|
71
|
+
const result = ts.transform(statement, [
|
|
72
|
+
(context) => (root) => {
|
|
73
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
74
|
+
const visited = ts.visitEachChild(node, visit, context);
|
|
75
|
+
if (!ts.isUnionTypeNode(visited)) return visited;
|
|
76
|
+
const sorted = visited.types
|
|
77
|
+
.map((type) => ({ type, key: ApiDeclarationPrinter.printType(type, sourceFile) }))
|
|
78
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
79
|
+
.map(({ type }) => type);
|
|
80
|
+
return context.factory.updateUnionTypeNode(visited, context.factory.createNodeArray(sorted));
|
|
81
|
+
};
|
|
82
|
+
return ts.visitNode(root, visit) as ts.Statement;
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
const [transformed = statement] = result.transformed;
|
|
86
|
+
result.dispose();
|
|
87
|
+
return transformed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static #withoutPrivateMembers(declaration: ts.ClassDeclaration) {
|
|
91
|
+
const members = declaration.members.filter(
|
|
92
|
+
(member) =>
|
|
93
|
+
ts.isConstructorDeclaration(member) ||
|
|
94
|
+
!(
|
|
95
|
+
(member.name && ts.isPrivateIdentifier(member.name)) ||
|
|
96
|
+
(ts.canHaveModifiers(member) &&
|
|
97
|
+
ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword))
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
if (members.length === declaration.members.length) return declaration;
|
|
101
|
+
return ts.factory.updateClassDeclaration(
|
|
102
|
+
declaration,
|
|
103
|
+
declaration.modifiers,
|
|
104
|
+
declaration.name,
|
|
105
|
+
declaration.typeParameters,
|
|
106
|
+
declaration.heritageClauses,
|
|
107
|
+
members,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #tagsOf(node: ts.Node) {
|
|
112
|
+
const target = ts.isVariableStatement(node) ? (node.declarationList.declarations[0] ?? node) : node;
|
|
113
|
+
const names = ts.getJSDocTags(target).map((tag) => tag.tagName.text);
|
|
114
|
+
return ApiDeclarationPrinter.#reportedTags.filter((tag) => names.includes(tag));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static #escape(value: string) {
|
|
118
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
}
|
|
120
|
+
}
|