@akanjs/devkit 3.1.0-rc.2 → 3.1.0-rc.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/abstractDoc.ts +1 -1
  2. package/agentsIndex.test.ts +4 -1
  3. package/akanConfig/akanConfig.ts +2 -2
  4. package/akanContext.ts +4 -4
  5. package/akanMcpContract.ts +1 -1
  6. package/applicationBuildRunner.test.ts +24 -1
  7. package/applicationBuildRunner.ts +11 -1
  8. package/artifact/implicitRootLayout.test.ts +2 -0
  9. package/artifact/implicitRootLayout.ts +1 -0
  10. package/biome.base.json +1 -11
  11. package/biomeBase.ts +1 -1
  12. package/cloud/configLock.ts +2 -1
  13. package/cloud/globalConfig.ts +3 -2
  14. package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
  15. package/codeAgent/agent/AkanCodeServices.ts +2 -3
  16. package/codeAgent/agent/CodeAgent.ts +46 -2
  17. package/codeAgent/agent/CodeAgentProxy.ts +2 -2
  18. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  19. package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
  20. package/codeAgent/agent/akanCodeModel.ts +1 -1
  21. package/codeAgent/agent/akanContextFiles.test.ts +3 -1
  22. package/codeAgent/agent/codeAgent.test.ts +61 -3
  23. package/codeAgent/agent/codeAgentContract.test.ts +1 -0
  24. package/codeAgent/index.ts +1 -0
  25. package/codeAgent/skills/akan-code/SKILL.md +4 -0
  26. package/codeAgent/skills/akan-store/SKILL.md +2 -1
  27. package/codeAgent/tools/AkanCodePlugins.ts +3 -1
  28. package/codeAgent/tools/DeepSeekWeb.ts +127 -0
  29. package/codeAgent/tools/WebToolPack.ts +128 -29
  30. package/codeAgent/tools/akanCodePlugins.test.ts +143 -16
  31. package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
  32. package/commandDecorators/command.ts +5 -4
  33. package/createTunnel.test.ts +14 -0
  34. package/createTunnel.ts +3 -1
  35. package/dependencyScanner.ts +1 -1
  36. package/devkitUtils.test.ts +1 -1
  37. package/executors.test.ts +85 -3
  38. package/executors.ts +30 -13
  39. package/frontendBuild/autoImportSync.test.ts +3 -1
  40. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  41. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  42. package/frontendBuild/csrArtifactBuilder.ts +5 -3
  43. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  44. package/frontendBuild/cssBlockParser.test.ts +123 -0
  45. package/frontendBuild/cssBlockParser.ts +132 -0
  46. package/frontendBuild/cssCandidateCache.test.ts +16 -0
  47. package/frontendBuild/cssCandidateCache.ts +8 -6
  48. package/frontendBuild/cssCompiler.test.ts +53 -0
  49. package/frontendBuild/cssCompiler.ts +8 -4
  50. package/frontendBuild/fontOptimizer.ts +2 -1
  51. package/frontendBuild/frontendBuild.test.ts +14 -0
  52. package/frontendBuild/hmrWatcher.ts +1 -1
  53. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  54. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  55. package/frontendBuild/routeClientBuilder.ts +1 -1
  56. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  57. package/frontendBuild/themeValidator.test.ts +83 -2
  58. package/frontendBuild/themeValidator.ts +37 -15
  59. package/getRelatedCnsts.ts +2 -1
  60. package/integration/devStabilityHarness.ts +5 -3
  61. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  62. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  63. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  64. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  65. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  66. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  67. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  68. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  69. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  70. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  71. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  72. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  73. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  74. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  75. package/mobile/mobileTarget.ts +1 -2
  76. package/mobile/nativeApp.test.ts +1 -1
  77. package/mobile/nativeConfig.test.ts +3 -0
  78. package/mobile/nativeConfig.ts +3 -2
  79. package/mobile/nativeWebDir.ts +3 -2
  80. package/mobile/updateUploader.test.ts +1 -0
  81. package/mobile/updateUploader.ts +3 -3
  82. package/package.json +20 -20
  83. package/piDependencyPins.test.ts +18 -19
  84. package/platformTest/LinuxDockerTarget.ts +2 -1
  85. package/platformTest/PlatformTestTarget.ts +7 -5
  86. package/platformTest/WindowsSshTarget.ts +2 -1
  87. package/qualityScanner.ts +1 -1
  88. package/recipeScanner.test.ts +9 -3
  89. package/scanInfo.ts +1 -1
  90. package/subspaceClone.ts +5 -4
  91. package/testHelpers.ts +2 -0
  92. package/transforms/barrelImportsPlugin.ts +1 -1
  93. package/transforms/browserDirnamePlugin.test.ts +88 -0
  94. package/transforms/browserDirnamePlugin.ts +25 -0
  95. package/transforms/rscUseClientTransform.ts +2 -2
  96. package/transforms/transforms.test.ts +18 -2
  97. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  98. package/types.ts +1 -0
  99. package/workflow/artifacts.ts +1 -1
  100. package/workflow/rolloutGate.test.ts +5 -1
  101. package/workflow/source.ts +2 -2
@@ -0,0 +1,132 @@
1
+ export interface CssDeclaration {
2
+ kind: "declaration";
3
+ property: string;
4
+ value: string;
5
+ }
6
+
7
+ export interface CssBlock {
8
+ kind: "block";
9
+ prelude: string;
10
+ atRule: string | null;
11
+ nodes: CssNode[];
12
+ }
13
+
14
+ export type CssNode = CssDeclaration | CssBlock;
15
+
16
+ export class CssBlockParser {
17
+ readonly #css: string;
18
+ readonly #stack: CssBlock[] = [{ kind: "block", prelude: "", atRule: null, nodes: [] }];
19
+ #pendingParts: string[] = [];
20
+ #segmentStart = 0;
21
+ #parenDepth = 0;
22
+
23
+ constructor(css: string) {
24
+ this.#css = css;
25
+ }
26
+
27
+ static parse(css: string): CssBlock {
28
+ return new CssBlockParser(css).#run();
29
+ }
30
+
31
+ static walk<T>(root: CssBlock, context: T, visit: (node: CssNode, context: T) => T | null) {
32
+ const stack = [{ nodes: root.nodes, idx: 0, context }];
33
+ while (stack.length > 0) {
34
+ const frame = stack[stack.length - 1];
35
+ const node = frame.nodes[frame.idx];
36
+ if (!node) {
37
+ stack.pop();
38
+ continue;
39
+ }
40
+ frame.idx += 1;
41
+ const childContext = visit(node, frame.context);
42
+ if (node.kind === "block" && childContext !== null)
43
+ stack.push({ nodes: node.nodes, idx: 0, context: childContext });
44
+ }
45
+ }
46
+
47
+ #run(): CssBlock {
48
+ const css = this.#css;
49
+ let idx = 0;
50
+ while (idx < css.length) {
51
+ const char = css[idx];
52
+ if (char === "/" && css[idx + 1] === "*") {
53
+ this.#pendingParts.push(css.slice(this.#segmentStart, idx));
54
+ const end = css.indexOf("*/", idx + 2);
55
+ idx = end === -1 ? css.length : end + 2;
56
+ this.#segmentStart = idx;
57
+ continue;
58
+ }
59
+ if (char === "\\") {
60
+ idx += 2;
61
+ continue;
62
+ }
63
+ if (char === '"' || char === "'") {
64
+ idx = this.#skipString(idx);
65
+ continue;
66
+ }
67
+ if (char === "(") this.#parenDepth += 1;
68
+ else if (char === ")") this.#parenDepth = Math.max(0, this.#parenDepth - 1);
69
+ else if (char === "{") this.#openBlock(idx);
70
+ else if (char === "}") this.#closeBlock(idx);
71
+ //? A `;` inside parentheses belongs to the value, as in an unquoted `url(data:image/png;base64,…)`.
72
+ else if (char === ";" && this.#parenDepth === 0) this.#endStatement(idx);
73
+ idx += 1;
74
+ }
75
+ this.#endStatement(css.length);
76
+ return this.#stack[0];
77
+ }
78
+
79
+ get #current(): CssBlock {
80
+ return this.#stack[this.#stack.length - 1];
81
+ }
82
+
83
+ // An unterminated string ends at the newline, as the CSS tokenizer's bad-string does.
84
+ #skipString(start: number): number {
85
+ const css = this.#css;
86
+ const quote = css[start];
87
+ let idx = start + 1;
88
+ while (idx < css.length) {
89
+ const char = css[idx];
90
+ if (char === "\\") idx += 2;
91
+ else if (char === quote) return idx + 1;
92
+ else if (char === "\n") return idx;
93
+ else idx += 1;
94
+ }
95
+ return css.length;
96
+ }
97
+
98
+ #takeSegment(end: number): string {
99
+ const text = this.#pendingParts.join("") + this.#css.slice(this.#segmentStart, end);
100
+ this.#pendingParts = [];
101
+ this.#segmentStart = end + 1;
102
+ return text.trim();
103
+ }
104
+
105
+ #openBlock(idx: number) {
106
+ const prelude = this.#takeSegment(idx);
107
+ const block: CssBlock = { kind: "block", prelude, atRule: CssBlockParser.#atRuleOf(prelude), nodes: [] };
108
+ this.#current.nodes.push(block);
109
+ this.#stack.push(block);
110
+ this.#parenDepth = 0;
111
+ }
112
+
113
+ #closeBlock(idx: number) {
114
+ this.#endStatement(idx);
115
+ if (this.#stack.length > 1) this.#stack.pop();
116
+ this.#parenDepth = 0;
117
+ }
118
+
119
+ #endStatement(idx: number) {
120
+ const text = this.#takeSegment(idx);
121
+ if (!text || text.startsWith("@")) return;
122
+ const colon = text.indexOf(":");
123
+ if (colon <= 0) return;
124
+ const property = text.slice(0, colon).trim();
125
+ if (!/^[\w-]+$/.test(property)) return;
126
+ this.#current.nodes.push({ kind: "declaration", property, value: text.slice(colon + 1).trim() });
127
+ }
128
+
129
+ static #atRuleOf(prelude: string): string | null {
130
+ return prelude.match(/^@[\w-]+/)?.[0].toLowerCase() ?? null;
131
+ }
132
+ }
@@ -23,6 +23,22 @@ describe("CssCandidateCache token extraction", () => {
23
23
  expect(candidates).not.toContain("_td");
24
24
  });
25
25
 
26
+ test("keeps a bracket nested one level inside another whole, rather than cutting it at the inner `]`", async () => {
27
+ const candidates = await scan(
28
+ `const c = "hidden [[data-theme=light]_&]:block has-[[aria-expanded=true]]:opacity-100 [&_[data-x]]:p-2 [&:has([data-state=open])]:p-1";`,
29
+ );
30
+ expect(candidates).toEqual(
31
+ expect.arrayContaining([
32
+ "[[data-theme=light]_&]:block",
33
+ "has-[[aria-expanded=true]]:opacity-100",
34
+ "[&_[data-x]]:p-2",
35
+ "[&:has([data-state=open])]:p-1",
36
+ ]),
37
+ );
38
+ expect(candidates).not.toContain("[[data-theme=light]_");
39
+ expect(candidates).not.toContain("[&_[data-x]");
40
+ });
41
+
26
42
  test("keeps Tailwind v4's variable shorthand whole, and leaves a call's argument its own candidate", async () => {
27
43
  const candidates = await scan(
28
44
  `const c = "max-md:top-(--keyboard-top) min-h-(--x) bg-(color:--brand)/50"; cn("flex"); fn(value);`,
@@ -1,10 +1,12 @@
1
1
  import { stat } from "node:fs/promises";
2
2
 
3
- // The leading `[` alternation keeps arbitrary variants (`[&_td]:px-3`) whole. Brackets hold no whitespace: a labelled
4
- // tuple type scanned as a class compiles to a rule lightningcss cannot parse, and it then drops every variant rule.
5
- // Parentheses pass only as Tailwind v4's variable shorthand, `(--x)` or `(length:--x)`, so `cn("flex")` still yields
6
- // `flex` on its own.
7
- const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:(?:\[[^\]\s]+\]|\((?:[a-z-]+:)?--[^)\s]+\))[\w:/.-]*)*/g;
3
+ // The leading `[` alternation keeps arbitrary variants (`[&_td]:px-3`) whole, and a bracket may hold one nested
4
+ // bracket (`[&_[data-slot=x]]:p-2`, `has-[[aria-expanded=true]]:x`), or the candidate is cut at the inner `]` and the
5
+ // rule silently never compiles. Brackets hold no whitespace: a labelled tuple type scanned as a class compiles to a
6
+ // rule lightningcss cannot parse, and it then drops every variant rule. Parentheses pass only as Tailwind v4's
7
+ // variable shorthand, `(--x)` or `(length:--x)`, so `cn("flex")` still yields `flex` on its own.
8
+ const CANDIDATE_RE =
9
+ /-?(?:[\w@]|\[(?:[^[\]\s]|\[[^[\]\s]+\])+\])[\w:/.-]*(?:(?:\[(?:[^[\]\s]|\[[^[\]\s]+\])+\]|\((?:[a-z-]+:)?--[^)\s]+\))[\w:/.-]*)*/g;
8
10
 
9
11
  interface CachedFile {
10
12
  mtimeMs: number;
@@ -20,7 +22,7 @@ interface CacheFile {
20
22
  /** Tailwind candidates per source file, on disk: the batch worker that compiles CSS exits before the next save. */
21
23
  export class CssCandidateCache {
22
24
  // Bump when the token regex or the entry shape changes, so stale extractions are not reused.
23
- static readonly #version = 4;
25
+ static readonly #version = 5;
24
26
  readonly #path: string;
25
27
  readonly #entries = new Map<string, CachedFile>();
26
28
  #dirty = false;
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { declaredCustomProperties } from "./cssCompiler";
3
+
4
+ describe("declaredCustomProperties", () => {
5
+ test("names what the regex-based version named", () => {
6
+ expect(declaredCustomProperties(":root { --kakao: #fee500; --naver: #1ec800; }")).toEqual(["--kakao", "--naver"]);
7
+ expect(declaredCustomProperties("@theme inline {\n --color-brand: var(--brand);\n}\n")).toEqual([]);
8
+ expect(declaredCustomProperties("@theme { --color-x: initial; }\n:root { --brand: #111; }")).toEqual(["--brand"]);
9
+ expect(declaredCustomProperties(".a { color: var(--kakao); }")).toEqual([]);
10
+ expect(declaredCustomProperties(":root{--a:#111}\n@media (min-width:1px){:root{--a:#222;--b:#333}}")).toEqual([
11
+ "--a",
12
+ "--b",
13
+ ]);
14
+ expect(declaredCustomProperties(":root { --a : 1; --b:2 }")).toEqual(["--a", "--b"]);
15
+ expect(declaredCustomProperties(".a{--x:1}.b{--y:2}.c{--z:3}")).toEqual(["--x", "--y", "--z"]);
16
+ expect(declaredCustomProperties("--top: 1;")).toEqual(["--top"]);
17
+ expect(declaredCustomProperties("@property --percentage { syntax: '<number>'; inherits: false; }")).toEqual([]);
18
+ });
19
+
20
+ test("keeps source order across nested rules", () => {
21
+ expect(declaredCustomProperties(".x { --a: 1; .y { --b: 2; } --c: 3; }")).toEqual(["--a", "--b", "--c"]);
22
+ });
23
+
24
+ test("skips a whole @theme block, nested blocks included", () => {
25
+ const css = `@theme { --animate-spin: spin 1s; @keyframes spin { to { --inner: 1; } } --color-after: red; } :root { --kept: 1; }`;
26
+ expect(declaredCustomProperties(css)).toEqual(["--kept"]);
27
+ });
28
+
29
+ test("ignores comments and strings, so a comment mentioning @theme hides nothing", () => {
30
+ const css = [
31
+ "/* `@theme` extension does not work from a lib, so these are plain variables. */",
32
+ ":root { --kakao: #fee500; }",
33
+ "/* :root { --commented: 1; } */",
34
+ `.a::before { content: "--quoted: 1"; }`,
35
+ ].join("\n");
36
+ expect(declaredCustomProperties(css)).toEqual(["--kakao"]);
37
+ });
38
+
39
+ test("adversarial input finishes well under a second", () => {
40
+ for (const css of [
41
+ "@theme".repeat(100_000),
42
+ "@theme{".repeat(100_000),
43
+ `@theme ${"x".repeat(100_000)}`,
44
+ `:root{${"--".repeat(100_000)}`,
45
+ " --a".repeat(100_000),
46
+ "@media x{".repeat(100_000),
47
+ ]) {
48
+ const started = performance.now();
49
+ declaredCustomProperties(css);
50
+ expect(performance.now() - started).toBeLessThan(1000);
51
+ }
52
+ });
53
+ });
@@ -6,6 +6,7 @@ import type { App } from "../commandDecorators";
6
6
  import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
7
7
  import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
8
8
  import { CsrEntryFiles } from "./csrEntryFiles";
9
+ import { CssBlockParser } from "./cssBlockParser";
9
10
  import { CssCandidateCache } from "./cssCandidateCache";
10
11
  import { CssImportResolver } from "./cssImportResolver";
11
12
 
@@ -387,10 +388,13 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
387
388
 
388
389
  /** Skips `@theme` blocks: Tailwind emits those variables only when a utility uses one. */
389
390
  export function declaredCustomProperties(css: string): string[] {
390
- const withoutThemeBlocks = css.replace(/@theme[^{]*\{[^}]*\}/g, "");
391
- return [...new Set([...withoutThemeBlocks.matchAll(/(?:^|[\s;{])(--[\w-]+)\s*:/g)].map(([, name]) => name))].filter(
392
- (name): name is string => !!name,
393
- );
391
+ const names = new Set<string>();
392
+ CssBlockParser.walk(CssBlockParser.parse(css), true, (node) => {
393
+ if (node.kind === "block") return node.atRule === "@theme" ? null : true;
394
+ if (node.property.startsWith("--")) names.add(node.property);
395
+ return null;
396
+ });
397
+ return [...names];
394
398
  }
395
399
 
396
400
  export function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
@@ -425,7 +425,8 @@ export class FontOptimizer {
425
425
  if (!isDir) continue;
426
426
  const filePaths: string[] = [];
427
427
  for await (const filePath of glob.scan({ cwd: root, absolute: true })) filePaths.push(filePath);
428
- for (const filePath of filePaths.sort()) parts.push(await Bun.file(filePath).text());
428
+ for (const filePath of filePaths.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)))
429
+ parts.push(await Bun.file(filePath).text());
429
430
  }
430
431
  return parts.join("");
431
432
  }
@@ -195,6 +195,20 @@ describe("CsrArtifactBuilder", () => {
195
195
  );
196
196
  });
197
197
 
198
+ test("blanks embedded bodies the way the HTML tokenizer closes them", () => {
199
+ const blank = (html: string) => CsrArtifactBuilder.blankEmbeddedContent(html).trim();
200
+
201
+ expect(blank("<script>a</script >")).toBe("");
202
+ expect(blank("<script>a</script\t\n bar>")).toBe("");
203
+ expect(blank("<SCRIPT>a</SCRIPT>")).toBe("");
204
+ expect(blank("<style>a</style foo>")).toBe("");
205
+ expect(blank("<!-- a --!></head>")).toBe("</head>");
206
+ expect(blank("<script>a</scripts></head>")).toBe("");
207
+ expect(blank("<!-- a </head>")).toBe("");
208
+ expect(CsrArtifactBuilder.blankEmbeddedContent(`<!--${"<!--".repeat(50_000)}`)).toHaveLength(200_004);
209
+ expect(CsrArtifactBuilder.blankEmbeddedContent(`<script>${"</script\t".repeat(50_000)}`)).toHaveLength(450_008);
210
+ });
211
+
198
212
  test("gives each basePath its own routes plus the routes outside every basePath", () => {
199
213
  const entries = [
200
214
  { key: "./_layout.tsx", moduleAbsPath: "/repo/page/_layout.tsx" },
@@ -197,7 +197,7 @@ export class HmrWatcher {
197
197
  const gaps = this.#index.coverageGaps;
198
198
  const key = gaps
199
199
  .map((gap) => `${gap.code}:${gap.path}`)
200
- .sort()
200
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
201
201
  .join("|");
202
202
  if (key === this.#reportedGaps) return;
203
203
  this.#reportedGaps = key;
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PagesBundleBuilder } from "./pagesBundleBuilder";
3
+
4
+ const macroImport = `import { getSerializedSignal } from "./sig" with { type: "macro" };`;
5
+ const macroFetchProto =
6
+ "const fetchProto = FetchClient.build<typeof signal>(cnst, getSerializedSignal(), { Err: pageProto.Err });";
7
+ const serverImport = `import { fetch as serverFetch } from "./sig";`;
8
+ const serverFetchProto =
9
+ "const fetchProto = FetchClient.build<typeof signal>(cnst, serverFetch.serializedSignal, { Err: pageProto.Err, base: serverFetch });";
10
+
11
+ const generatedUseClient = (lines: { importLine?: string; fetchProtoLine?: string } = {}) =>
12
+ [
13
+ 'import { makePageProto, registerClientRuntime } from "akanjs/client";',
14
+ 'import { FetchClient } from "akanjs/fetch";',
15
+ 'import * as cnst from "./cnst";',
16
+ 'import type * as dict from "./dict";',
17
+ lines.importLine ?? macroImport,
18
+ 'import type * as signal from "./sig";',
19
+ "",
20
+ "// dict.ts is cumulative, so a lib dictionary is a subset of the mounting app's. Translator state is global,",
21
+ "const dictionary = {};",
22
+ "const pageProto = makePageProto<typeof dict>(dictionary);",
23
+ lines.fetchProtoLine ?? macroFetchProto,
24
+ 'export const runtime = registerClientRuntime({ ...pageProto, ...fetchProto }, { scope: "lib" });',
25
+ "",
26
+ "export const { msg, Err, usePage, sig, fetch } = runtime;",
27
+ "",
28
+ ].join("\n");
29
+
30
+ const transform = (source: string) => PagesBundleBuilder.transformServerUseClientFetchSource(source);
31
+
32
+ describe("PagesBundleBuilder.transformServerUseClientFetchSource", () => {
33
+ test("swaps the signal macro for the live server fetch in the generated useClient.ts", () => {
34
+ expect(transform(generatedUseClient())).toBe(
35
+ generatedUseClient({ importLine: serverImport, fetchProtoLine: serverFetchProto }),
36
+ );
37
+ });
38
+
39
+ test("accepts the spacing and quoting variants the regex-based version accepted", () => {
40
+ const source = generatedUseClient({
41
+ importLine: `import {getSerializedSignal} from './sig' with {type : 'macro'};`,
42
+ fetchProtoLine: [
43
+ "const fetchProto=FetchClient.build<typeof signal>(",
44
+ " cnst,",
45
+ " getSerializedSignal(),",
46
+ " { Err: pageProto.Err },",
47
+ ");",
48
+ ].join("\n"),
49
+ }).replace("import type * as dict", `import { x } from "./x" with { type: "macro" };\nimport type * as dict`);
50
+ const transformed = transform(source);
51
+ expect(transformed).toContain(`\n${serverImport}\n`);
52
+ expect(transformed).toContain(`\n${serverFetchProto}\nexport const runtime`);
53
+ expect(transformed).not.toContain("getSerializedSignal");
54
+ });
55
+
56
+ test("keeps an export modifier in front of the rewritten declaration", () => {
57
+ const transformed = transform(generatedUseClient({ fetchProtoLine: `export ${macroFetchProto}` }));
58
+ expect(transformed).toContain(`\nexport ${serverFetchProto}\n`);
59
+ });
60
+
61
+ test("leaves comments and strings that spell the statements alone", () => {
62
+ const source = generatedUseClient().replace(
63
+ "const dictionary = {};",
64
+ `// ${macroFetchProto}\nconst note = "${macroImport.replaceAll('"', "'")}";\nconst dictionary = {};`,
65
+ );
66
+ const transformed = transform(source);
67
+ expect(transformed).toContain(`// ${macroFetchProto}\n`);
68
+ expect(transformed).toContain(`const note = "${macroImport.replaceAll('"', "'")}";`);
69
+ expect(transformed).toContain(`\n${serverImport}\n`);
70
+ expect(transformed).toContain(`\n${serverFetchProto}\n`);
71
+ });
72
+
73
+ test("returns sources it does not recognise unchanged", () => {
74
+ const transformed = generatedUseClient({ importLine: serverImport, fetchProtoLine: serverFetchProto });
75
+ expect(transform(transformed)).toBe(transformed);
76
+ const typeOnly = generatedUseClient({ importLine: macroImport.replace("import {", "import type {") });
77
+ expect(transform(typeOnly)).toContain(`import type { getSerializedSignal } from "./sig" with { type: "macro" };`);
78
+ const aliased = generatedUseClient({
79
+ importLine: macroImport.replace("getSerializedSignal", "getSerializedSignal as g"),
80
+ });
81
+ expect(transform(aliased)).toContain(`import { getSerializedSignal as g } from "./sig" with { type: "macro" };`);
82
+ const withoutMacro = 'import { x } from "./y";\n';
83
+ expect(transform(withoutMacro)).toBe(withoutMacro);
84
+ });
85
+
86
+ test("adversarial input finishes well under a second", () => {
87
+ const marker = `${macroImport}\nFetchClient.build\n`;
88
+ for (const source of [
89
+ marker + "const fetchProto=FetchClient.build<".repeat(100_000),
90
+ marker + `import { getSerializedSignal } from "./sig" with { type: `.repeat(100_000),
91
+ marker + "{".repeat(100_000),
92
+ ]) {
93
+ const started = performance.now();
94
+ transform(source);
95
+ expect(performance.now() - started).toBeLessThan(1000);
96
+ }
97
+ });
98
+ });
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { BunPlugin } from "bun";
4
+ import ts from "typescript";
4
5
  import type { PageEntry } from "../artifact/implicitRootLayout";
5
6
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
6
7
  import type { App } from "../commandDecorators";
@@ -25,6 +26,12 @@ export interface BuildPagesBundleResult {
25
26
  chunkCount: number;
26
27
  }
27
28
 
29
+ interface TextSpan {
30
+ start: number;
31
+ end: number;
32
+ text: string;
33
+ }
34
+
28
35
  const VIRTUAL_PAGES_ENTRY = "akan-pages-entry";
29
36
 
30
37
  export class PagesBundleBuilder {
@@ -174,14 +181,62 @@ export class PagesBundleBuilder {
174
181
 
175
182
  static transformServerUseClientFetchSource(source: string): string {
176
183
  if (!source.includes(`with { type: "macro" }`) || !source.includes("FetchClient.build")) return source;
177
- return source
178
- .replace(
179
- /import\s+\{\s*getSerializedSignal\s*\}\s+from\s+["']\.\/sig["']\s+with\s+\{\s*type\s*:\s*["']macro["']\s*\};/,
180
- `import { fetch as serverFetch } from "./sig";`,
181
- )
182
- .replace(
183
- /const\s+fetchProto\s*=\s*FetchClient\.build<[^;]+;/,
184
- "const fetchProto = FetchClient.build<typeof signal>(cnst, serverFetch.serializedSignal, { Err: pageProto.Err, base: serverFetch });",
185
- );
184
+ const tokens = PagesBundleBuilder.#tokenize(source);
185
+ const edits: TextSpan[] = [];
186
+ const importAt = PagesBundleBuilder.#indexOfTokens(tokens, PagesBundleBuilder.#macroImportTokens);
187
+ if (importAt !== -1)
188
+ edits.push({
189
+ start: tokens[importAt].start,
190
+ end: tokens[importAt + PagesBundleBuilder.#macroImportTokens.length - 1].end,
191
+ text: `import { fetch as serverFetch } from "./sig";`,
192
+ });
193
+ const fetchProtoAt = PagesBundleBuilder.#indexOfTokens(tokens, PagesBundleBuilder.#fetchProtoTokens);
194
+ const fetchProtoEnd = fetchProtoAt === -1 ? undefined : tokens.slice(fetchProtoAt).find(({ text }) => text === ";");
195
+ if (fetchProtoEnd)
196
+ edits.push({
197
+ start: tokens[fetchProtoAt].start,
198
+ end: fetchProtoEnd.end,
199
+ text: "const fetchProto = FetchClient.build<typeof signal>(cnst, serverFetch.serializedSignal, { Err: pageProto.Err, base: serverFetch });",
200
+ });
201
+ let transformed = source;
202
+ for (const { start, end, text } of edits.sort((a, b) => b.start - a.start))
203
+ transformed = transformed.slice(0, start) + text + transformed.slice(end);
204
+ return transformed;
205
+ }
206
+
207
+ static readonly #macroImportTokens = [
208
+ "import",
209
+ "{",
210
+ "getSerializedSignal",
211
+ "}",
212
+ "from",
213
+ '"./sig"',
214
+ "with",
215
+ "{",
216
+ "type",
217
+ ":",
218
+ '"macro"',
219
+ "}",
220
+ ";",
221
+ ] as const;
222
+ static readonly #fetchProtoTokens = ["const", "fetchProto", "=", "FetchClient", ".", "build", "<"] as const;
223
+
224
+ //? The scanner, not the parser: parsing recurses per nesting level and overflows the stack on a deep `{` run.
225
+ static #tokenize(source: string): TextSpan[] {
226
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, source);
227
+ const tokens: TextSpan[] = [];
228
+ for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
229
+ const text =
230
+ kind === ts.SyntaxKind.StringLiteral ? JSON.stringify(scanner.getTokenValue()) : scanner.getTokenText();
231
+ tokens.push({ text, start: scanner.getTokenStart(), end: scanner.getTokenEnd() });
232
+ }
233
+ return tokens;
234
+ }
235
+
236
+ static #indexOfTokens(tokens: TextSpan[], pattern: readonly string[]): number {
237
+ for (let idx = 0; idx + pattern.length <= tokens.length; idx += 1) {
238
+ if (pattern.every((text, offset) => tokens[idx + offset].text === text)) return idx;
239
+ }
240
+ return -1;
186
241
  }
187
242
  }
@@ -244,7 +244,7 @@ export class RouteClientBuilder {
244
244
  //? a probe export and only the ones the parser reports as exports are declarations.
245
245
  static #declaredNamedDefaultFunctions(source: string, filePath?: string) {
246
246
  const candidates = [
247
- ...source.matchAll(/(^|\n)(\s*)export\s+default\s+(async\s+)?function\s+([A-Za-z_$][\w$]*)(?=\s*(?:<|\())/g),
247
+ ...source.matchAll(/(^|\n)([^\S\n]*)export\s+default\s+(async\s+)?function\s+([A-Za-z_$][\w$]*)(?=\s*(?:<|\())/g),
248
248
  ].map((match) => ({
249
249
  index: match.index,
250
250
  text: match[0],
@@ -50,7 +50,7 @@ describe("SourceMtimeIndex", () => {
50
50
 
51
51
  for (const [i, abs] of files.entries()) await rewrite(abs, `${i}00`);
52
52
 
53
- expect((await index.collectChanges()).sort()).toEqual([...files].sort());
53
+ expect((await index.collectChanges()).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([...files].sort());
54
54
  });
55
55
 
56
56
  test("reports a change once, then stops reporting it", async () => {
@@ -37,6 +37,67 @@ describe("ThemeValidator.parseThemeTokens", () => {
37
37
  const tokens = ThemeValidator.parseThemeTokens(css);
38
38
  expect(Object.keys(tokens)).toEqual([":root"]);
39
39
  });
40
+
41
+ test("reads every block, not every other one", () => {
42
+ const css = `.a {} :root { --background: #fff; } .b {} [data-theme='light'] { --background: #eee; } [data-theme="dark"] { --background: #111; }`;
43
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({
44
+ ":root": { background: "#fff" },
45
+ '[data-theme="light"]': { background: "#eee" },
46
+ '[data-theme="dark"]': { background: "#111" },
47
+ });
48
+ });
49
+
50
+ test("reads the first block after @source and @import lines", () => {
51
+ const css = [
52
+ `@source "./**/*";`,
53
+ `@import "tailwindcss";`,
54
+ `@plugin "tailwind-scrollbar";`,
55
+ `:root { --background: #ffffff; --foreground: #0a0a0a; }`,
56
+ `[data-theme="dark"] { --background: #0a0a0a; --foreground: #fafafa; }`,
57
+ `[data-theme="light"] { --background: #fafafa; --foreground: #171717; }`,
58
+ ].join("\n");
59
+ expect(Object.keys(ThemeValidator.parseThemeTokens(css))).toEqual([
60
+ ":root",
61
+ '[data-theme="dark"]',
62
+ '[data-theme="light"]',
63
+ ]);
64
+ });
65
+
66
+ test("keeps a block's own declarations, skipping comments and nested rules", () => {
67
+ const css = `:root { /* --background: #000; */ --background: #fff; .x { --foreground: #000; } --foreground: #111 }`;
68
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({ ":root": { background: "#fff", foreground: "#111" } });
69
+ });
70
+
71
+ test("merges a scope declared twice, the later value winning", () => {
72
+ const css = `:root { --background: #fff; --foreground: #000; } :root { --foreground: #111; }`;
73
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({ ":root": { background: "#fff", foreground: "#111" } });
74
+ });
75
+
76
+ test("reads through @layer and keys a conditional scope by its condition", () => {
77
+ const css = [
78
+ `@layer base { :root { --background: #fff; --foreground: #000; } }`,
79
+ `@media (prefers-color-scheme: dark) { :root { --background: #000; } }`,
80
+ ].join("\n");
81
+ expect(ThemeValidator.parseThemeTokens(css)).toEqual({
82
+ ":root": { background: "#fff", foreground: "#000" },
83
+ "@media (prefers-color-scheme: dark) :root": { background: "#000" },
84
+ });
85
+ });
86
+
87
+ test("adversarial input finishes well under a second", () => {
88
+ for (const css of [
89
+ " ".repeat(100_000),
90
+ `}${" ".repeat(100_000)}`,
91
+ `:root{${"--".repeat(100_000)}`,
92
+ `:root{---:${" ".repeat(100_000)}`,
93
+ ":root{--background:#fff}".repeat(100_000),
94
+ `${"@media x{".repeat(100_000)}:root{--background:#fff}`,
95
+ ]) {
96
+ const started = performance.now();
97
+ ThemeValidator.parseThemeTokens(css);
98
+ expect(performance.now() - started).toBeLessThan(1000);
99
+ }
100
+ });
40
101
  });
41
102
 
42
103
  describe("ThemeValidator.validate", () => {
@@ -58,12 +119,32 @@ describe("ThemeValidator.validate", () => {
58
119
  const css = `:root { --primary: var(--brand); --primary-foreground: #000000; }`;
59
120
  expect(validator.validate(css)).toHaveLength(0);
60
121
  });
122
+
123
+ test("checks a conditional scope over the scope it overrides", () => {
124
+ const css = [
125
+ `:root { --background: #ffffff; --foreground: #000000; }`,
126
+ `@media (prefers-color-scheme: dark) { :root { --background: #111111; } }`,
127
+ ].join("\n");
128
+ const violations = validator.validate(css);
129
+ expect(violations.map(({ scope, pair }) => [scope, pair])).toEqual([
130
+ ["@media (prefers-color-scheme: dark) :root", "background / foreground"],
131
+ ]);
132
+ });
61
133
  });
62
134
 
63
135
  describe("shipped framework palette", () => {
136
+ const css = readFileSync(path.resolve(import.meta.dir, "../../../akanjs/ui/styles.css"), "utf8");
137
+
138
+ test("pkgs/akanjs/ui/styles.css parses :root, dark and light", () => {
139
+ const tokens = ThemeValidator.parseThemeTokens(css);
140
+ expect(Object.keys(tokens)).toEqual([":root", '[data-theme="dark"]', '[data-theme="light"]']);
141
+ expect(tokens[":root"].background).toBe("#0a0a0a");
142
+ expect(tokens['[data-theme="dark"]'].background).toBe("#0a0a0a");
143
+ expect(tokens['[data-theme="light"]'].background).toBe("#ffffff");
144
+ for (const scopeTokens of Object.values(tokens)) expect(Object.keys(scopeTokens).length).toBeGreaterThanOrEqual(26);
145
+ });
146
+
64
147
  test("pkgs/akanjs/ui/styles.css passes WCAG contrast for all scopes", () => {
65
- const stylesPath = path.resolve(import.meta.dir, "../../../akanjs/ui/styles.css");
66
- const css = readFileSync(stylesPath, "utf8");
67
148
  const violations = validator.validate(css);
68
149
  expect(violations).toEqual([]);
69
150
  });