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

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 (106) 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 +3 -12
  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/argMeta.ts +1 -0
  33. package/commandDecorators/command.ts +52 -14
  34. package/commandDecorators/commandBuilder.ts +6 -3
  35. package/commandDecorators/commandDecorators.test.ts +130 -6
  36. package/commandDecorators/helpFormatter.ts +2 -1
  37. package/commandDecorators/targetMeta.ts +10 -0
  38. package/createTunnel.test.ts +14 -0
  39. package/createTunnel.ts +3 -1
  40. package/dependencyScanner.ts +1 -1
  41. package/devkitUtils.test.ts +1 -1
  42. package/executors.test.ts +108 -3
  43. package/executors.ts +30 -13
  44. package/frontendBuild/autoImportSync.test.ts +3 -1
  45. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  46. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  47. package/frontendBuild/csrArtifactBuilder.ts +5 -3
  48. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  49. package/frontendBuild/cssBlockParser.test.ts +123 -0
  50. package/frontendBuild/cssBlockParser.ts +132 -0
  51. package/frontendBuild/cssCandidateCache.test.ts +16 -0
  52. package/frontendBuild/cssCandidateCache.ts +8 -6
  53. package/frontendBuild/cssCompiler.test.ts +53 -0
  54. package/frontendBuild/cssCompiler.ts +8 -4
  55. package/frontendBuild/fontOptimizer.ts +2 -1
  56. package/frontendBuild/frontendBuild.test.ts +14 -0
  57. package/frontendBuild/hmrWatcher.ts +1 -1
  58. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  59. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  60. package/frontendBuild/routeClientBuilder.ts +1 -1
  61. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  62. package/frontendBuild/themeValidator.test.ts +83 -2
  63. package/frontendBuild/themeValidator.ts +37 -15
  64. package/getRelatedCnsts.ts +2 -1
  65. package/integration/devStabilityHarness.ts +5 -3
  66. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  67. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  68. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  69. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  70. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  71. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  72. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  73. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  74. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  75. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  76. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  77. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  78. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  79. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  80. package/mobile/mobileTarget.ts +1 -2
  81. package/mobile/nativeApp.test.ts +1 -1
  82. package/mobile/nativeConfig.test.ts +3 -0
  83. package/mobile/nativeConfig.ts +3 -2
  84. package/mobile/nativeWebDir.ts +3 -2
  85. package/mobile/updateUploader.test.ts +1 -0
  86. package/mobile/updateUploader.ts +3 -3
  87. package/package.json +20 -20
  88. package/piDependencyPins.test.ts +18 -19
  89. package/platformTest/LinuxDockerTarget.ts +2 -1
  90. package/platformTest/PlatformTestTarget.ts +7 -5
  91. package/platformTest/WindowsSshTarget.ts +2 -1
  92. package/qualityScanner.ts +1 -1
  93. package/recipeScanner.test.ts +9 -3
  94. package/scanInfo.ts +37 -4
  95. package/subspaceClone.ts +5 -4
  96. package/testHelpers.ts +2 -0
  97. package/transforms/barrelImportsPlugin.ts +1 -1
  98. package/transforms/browserDirnamePlugin.test.ts +88 -0
  99. package/transforms/browserDirnamePlugin.ts +25 -0
  100. package/transforms/rscUseClientTransform.ts +2 -2
  101. package/transforms/transforms.test.ts +18 -2
  102. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  103. package/types.ts +1 -0
  104. package/workflow/artifacts.ts +1 -1
  105. package/workflow/rolloutGate.test.ts +5 -1
  106. package/workflow/source.ts +2 -2
@@ -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
  });
@@ -1,4 +1,5 @@
1
1
  // No akanjs runtime import: this runs inside the lint path.
2
+ import { type CssBlock, CssBlockParser } from "./cssBlockParser";
2
3
 
3
4
  export interface ThemeContrastViolation {
4
5
  scope: string;
@@ -34,6 +35,7 @@ const PAIRS: PairDef[] = [
34
35
  ];
35
36
 
36
37
  const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
38
+ const CONDITIONAL_AT_RULES = new Set(["@media", "@supports", "@container"]);
37
39
 
38
40
  export type ThemeTokensByScope = Record<string, Record<string, string>>;
39
41
 
@@ -42,8 +44,9 @@ export class ThemeValidator {
42
44
  const tokensByScope = ThemeValidator.parseThemeTokens(css);
43
45
  const violations: ThemeContrastViolation[] = [];
44
46
  for (const [scope, tokens] of Object.entries(tokensByScope)) {
45
- if (!THEME_SCOPES.has(scope)) continue;
46
- violations.push(...this.validateScope(tokens, scope));
47
+ const underlying = THEME_SCOPES.has(scope) ? null : [...THEME_SCOPES].find((name) => scope.endsWith(` ${name}`));
48
+ const effective = underlying ? ThemeValidator.#overlayPairs(tokensByScope[underlying] ?? {}, tokens) : tokens;
49
+ violations.push(...this.validateScope(effective, scope));
47
50
  }
48
51
  return violations;
49
52
  }
@@ -75,23 +78,42 @@ export class ThemeValidator {
75
78
  /** A scope declared twice keeps the later value, so pass the framework css before the app's. */
76
79
  static parseThemeTokens(css: string): ThemeTokensByScope {
77
80
  const result: ThemeTokensByScope = {};
78
- const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
79
- for (const block of css.matchAll(blockRe)) {
80
- const selectors = block[1].split(",").map((s) => s.trim());
81
- const relevant = selectors.filter((s) => THEME_SCOPES.has(ThemeValidator.#normalizeScope(s)));
82
- if (relevant.length === 0) continue;
83
- const decls: Record<string, string> = {};
84
- for (const decl of block[2].matchAll(/--([\w-]+)\s*:\s*([^;]+?)\s*(?:;|$)/g)) {
85
- decls[decl[1]] = decl[2].trim();
81
+ CssBlockParser.walk(CssBlockParser.parse(css), "", (node, condition) => {
82
+ if (node.kind !== "block") return null;
83
+ if (node.atRule === "@layer") return condition;
84
+ if (node.atRule) return CONDITIONAL_AT_RULES.has(node.atRule) ? node.prelude.replace(/\s+/g, " ") : null;
85
+ const scopes = node.prelude
86
+ .split(",")
87
+ .map((selector) => ThemeValidator.#normalizeScope(selector))
88
+ .filter((scope) => THEME_SCOPES.has(scope));
89
+ if (scopes.length === 0) return null;
90
+ const tokens = ThemeValidator.#customProperties(node);
91
+ for (const scope of scopes) {
92
+ const key = condition ? `${condition} ${scope}` : scope;
93
+ result[key] = Object.assign(result[key] ?? {}, tokens);
86
94
  }
87
- for (const selector of relevant) {
88
- const scope = ThemeValidator.#normalizeScope(selector);
89
- result[scope] = { ...(result[scope] ?? {}), ...decls };
90
- }
91
- }
95
+ return null;
96
+ });
92
97
  return result;
93
98
  }
94
99
 
100
+ static #customProperties(block: CssBlock): Record<string, string> {
101
+ const tokens: Record<string, string> = {};
102
+ for (const node of block.nodes) {
103
+ if (node.kind === "declaration" && node.property.startsWith("--")) tokens[node.property.slice(2)] = node.value;
104
+ }
105
+ return tokens;
106
+ }
107
+
108
+ static #overlayPairs(underlying: Record<string, string>, tokens: Record<string, string>): Record<string, string> {
109
+ const effective: Record<string, string> = {};
110
+ for (const name of PAIRS.flatMap(({ base, fg }) => [base, fg])) {
111
+ const value = tokens[name] ?? underlying[name];
112
+ if (value) effective[name] = value;
113
+ }
114
+ return effective;
115
+ }
116
+
95
117
  static #normalizeScope(selector: string): string {
96
118
  return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
97
119
  }
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import ora from "ora";
3
4
  import * as ts from "typescript";
4
5
 
@@ -45,7 +46,7 @@ export const parseTsConfig = (tsConfigPath: string = "./tsconfig.json") => {
45
46
  return ts.sys.readFile(path);
46
47
  });
47
48
 
48
- return ts.parseJsonConfigFileContent(configFile.config, ts.sys, realpathSync(tsConfigPath).replace(/[^/\\]+$/, ""));
49
+ return ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(realpathSync(tsConfigPath)));
49
50
  };
50
51
 
51
52
  const collectFiles = (constantFilePath: string, parsedConfig: ts.ParsedCommandLine, includeExports: boolean) => {
@@ -772,9 +772,11 @@ export const dictionary = serviceDictionary(["en", "ko"])
772
772
  }
773
773
 
774
774
  static async #psRowsOrThrow(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
775
- const rows = (await DevStabilityHarness.#psRows()) ?? (await DevStabilityHarness.#psRows());
776
- if (!rows) throw new Error("ps did not answer twice in a row; cannot measure the process tree");
777
- return rows;
775
+ for (let attempt = 0; attempt < 2; attempt += 1) {
776
+ const rows = await DevStabilityHarness.#psRows();
777
+ if (rows) return rows;
778
+ }
779
+ throw new Error("ps did not answer twice in a row; cannot measure the process tree");
778
780
  }
779
781
 
780
782
  static #collectDescendants(rows: Array<{ pid: number; ppid: number }>, rootPid: number): Set<number> {
@@ -1,3 +1,4 @@
1
1
  import { userConstant } from "@libs/shared/lib/user/user.constant"; // @flag
2
2
  import { helper } from "../../common/helper"; // @flag
3
3
  import * as cnst from "../cnst"; // @flag
4
+ export { cnst, helper, userConstant };
@@ -1,3 +1,4 @@
1
1
  import { cnst, st } from "@apps/demo/client"; // @ok
2
2
  import { Task } from "./task.constant"; // @ok
3
3
  import { env } from "@apps/demo/env/env.client"; // @ok
4
+ export { cnst, env, st, Task };
@@ -1,2 +1,3 @@
1
1
  import { useState } from "react"; // @flag
2
2
  import { st } from "../st"; // @flag
3
+ export { st, useState };
@@ -2,3 +2,4 @@ import type { ReactNode } from "react"; // @ok
2
2
  import { cnst } from "@libs/shared/client"; // @ok
3
3
  import { usePage } from "akanjs/client"; // @ok
4
4
  export const Card = ({ children }: { children: ReactNode }) => <div>{children}</div>; // @ok
5
+ export { cnst, usePage };
@@ -4,3 +4,4 @@ import { Button } from "@libs/shared/ui"; // @flag
4
4
  import { useSpeech } from "@libs/util/webkit"; // @flag
5
5
  import { cn } from "akanjs/client"; // @flag
6
6
  import { st } from "../st"; // @flag
7
+ export { Button, Card, cn, st, TaskStore, useSpeech };
@@ -3,3 +3,4 @@ import * as cnst from "../cnst"; // @ok
3
3
  import * as db from "../db"; // @ok
4
4
  import { Err } from "../dict"; // @ok
5
5
  import { Admin } from "@libs/shared/srvkit"; // @ok
6
+ export { Admin, cnst, db, Err };
@@ -2,3 +2,4 @@ import dayjs from "dayjs"; // @flag
2
2
  import { z } from "zod"; // @flag
3
3
  import fs from "node:fs"; // @flag
4
4
  import { clsx } from "clsx"; // @flag
5
+ export { clsx, dayjs, fs, z };
@@ -3,3 +3,4 @@ import { cnst } from "@libs/shared/client"; // @ok
3
3
  import { field } from "akanjs/constant"; // @ok
4
4
  import { useState } from "react"; // @ok
5
5
  import { test } from "bun:test"; // @ok
6
+ export { cnst, field, test, Ticket, useState };
@@ -4,3 +4,4 @@ import fs from "node:fs"; // @ok
4
4
  import { Database } from "bun:sqlite"; // @ok
5
5
  import { describe } from "bun:test"; // @ok
6
6
  import { cnst } from "../cnst"; // @ok
7
+ export { cnst, Database, dayjs, describe, fs, z };
@@ -4,3 +4,4 @@ import { tmpdir } from "node:os"; // @ok
4
4
  import { Database } from "bun:sqlite"; // @ok
5
5
  import { describe, expect, test } from "bun:test"; // @ok
6
6
  import { fetch } from "../sig"; // @ok
7
+ export { createServer, Database, describe, expect, fetch, mkdtemp, test, tmpdir };
@@ -4,3 +4,4 @@ import { Admin } from "@libs/shared/srvkit"; // @flag
4
4
  import { AkanServer } from "akanjs/server"; // @flag
5
5
  import * as db from "../db"; // @flag
6
6
  import { Err } from "../dict"; // @flag
7
+ export { AkanServer, Admin, db, Err, TaskModel, TaskService };
@@ -2,3 +2,4 @@ import type { TaskService } from "./task.service"; // @ok
2
2
  import * as cnst from "../cnst"; // @ok
3
3
  import { st } from "../st"; // @ok
4
4
  import { Err, Task } from "@libs/shared/client"; // @ok
5
+ export { cnst, Err, st, Task };
@@ -1 +1 @@
1
- export class TaskStore { async reload(orgId: string) { const snapshot = await fetch.initTaskInOrg(orgId); } } // @flag
1
+ export class TaskStore { async reload(orgId: string) { await fetch.initTaskInOrg(orgId); } } // @flag
@@ -1,4 +1,4 @@
1
- import { enumOf, ID, Int } from "akanjs/base";
1
+ import { ID, Int } from "akanjs/base";
2
2
  import { via } from "akanjs/constant";
3
3
 
4
4
  export class QuizInput extends via((field) => ({
@@ -12,7 +12,7 @@ export class QuizObject extends via(QuizInput, (field) => ({
12
12
  static readonly minNum = 1; // @flag
13
13
  private static secretNum = 2; // @flag
14
14
  static { // @flag
15
- QuizObject.maxNum = 5;
15
+ QuizObject.maxNum += 1;
16
16
  }
17
17
  }
18
18
 
@@ -1,4 +1,5 @@
1
1
  import type { AkanNativeAppId } from "akanjs";
2
+ import { trimSlashes } from "akanjs/common";
2
3
  import type { AkanAppConfig, AkanNativeTarget, NativeEnv } from "../akanConfig";
3
4
  import type { App } from "../commandDecorators";
4
5
 
@@ -14,8 +15,6 @@ export interface ResolvedMobileTarget {
14
15
 
15
16
  export const MOBILE_ENVS = ["local", "debug", "develop", "main"] as const satisfies readonly NativeEnv[];
16
17
 
17
- const trimSlashes = (value: string) => value.replace(/^\/+|\/+$/g, "");
18
-
19
18
  export const getMobileTargets = async (app: App): Promise<ResolvedMobileTarget[]> => {
20
19
  const config = await app.getConfig();
21
20
  return Object.entries(config.native.targets).map(([name, target]) => ({ name, config: target }));
@@ -151,7 +151,7 @@ describe("NativeApp", () => {
151
151
 
152
152
  describe("the public env a build inlined, recorded in its bundle.json", () => {
153
153
  const publicEnv = { AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com" };
154
- const building = async (inlined: Record<string, string>, updates: object | undefined) => {
154
+ const building = async (inlined: Record<string, string>, updates: AkanNativeTarget["updates"]) => {
155
155
  const root = await makeTempRoot();
156
156
  const warned: string[] = [];
157
157
  const app = {
@@ -489,6 +489,9 @@ describe("NativeConfig.build", () => {
489
489
  expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
490
490
  expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
491
491
  expect(NativeConfig.fileNameOf("...")).toBe("app");
492
+ expect(NativeConfig.fileNameOf(".-my app!-.")).toBe("my-app");
493
+ expect(NativeConfig.fileNameOf("-a-")).toBe("a");
494
+ expect(NativeConfig.fileNameOf(`a${"-".repeat(50_000)}b`)).toHaveLength(50_002);
492
495
  });
493
496
  });
494
497
 
@@ -9,6 +9,7 @@ import type {
9
9
  NativeEnv,
10
10
  NativePermission,
11
11
  } from "akanjs";
12
+ import { trimSlashes } from "akanjs/common";
12
13
  import type { DesktopServerBundle } from "./desktopServerStage";
13
14
  import { type NativePlatform, resolveAppId } from "./mobileTarget";
14
15
  import type { NativePluginFolder } from "./nativePluginFolders";
@@ -271,7 +272,7 @@ export class NativeConfig {
271
272
 
272
273
  /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
273
274
  static fileNameOf(name: string) {
274
- return name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "app";
275
+ return /[^-.](?:.*[^-.])?/.exec(name.replace(/[^A-Za-z0-9._-]/g, "-"))?.[0] ?? "app";
275
276
  }
276
277
 
277
278
  //? A permission names a plugin by its id, and a plugin given by folder is named in its manifest; a spec the runtime
@@ -311,7 +312,7 @@ export class NativeConfig {
311
312
 
312
313
  //* Every route sits under /:lang, so an app link limited to a basePath lists it once per locale.
313
314
  static #deepLinks(target: AkanNativeTarget, locales: readonly string[]) {
314
- const basePath = target.basePath?.replace(/^\/+|\/+$/g, "");
315
+ const basePath = target.basePath ? trimSlashes(target.basePath) : "";
315
316
  const pathPrefixes = basePath ? locales.map((locale) => `/${locale}/${basePath}`) : undefined;
316
317
  return {
317
318
  ...(target.deepLinks?.schemes?.length ? { schemes: target.deepLinks.schemes } : {}),
@@ -1,6 +1,7 @@
1
1
  import { cp, mkdir, rm, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { AkanNativeTarget } from "akanjs";
4
+ import { trimSlashes } from "akanjs/common";
4
5
 
5
6
  export interface NativeWebDirSources {
6
7
  /** The target's CSR bundle: one HTML file with its scripts and styles inlined. */
@@ -21,13 +22,13 @@ export class NativeWebDir {
21
22
  //* it goes first in <head>, ahead of every script, because the page reads it as it boots.
22
23
  static injectTarget(html: string, target: Pick<AkanNativeTarget, "name" | "basePath" | "indexPath">) {
23
24
  if (html.includes(NativeWebDir.#targetTag)) return html;
24
- const basePath = target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
25
+ const basePath = target.basePath ? trimSlashes(target.basePath) : "";
25
26
  const script = `${NativeWebDir.#targetTag}${JSON.stringify({
26
27
  name: target.name,
27
28
  basePath,
28
29
  indexPath: target.indexPath,
29
30
  })};</script>`;
30
- const head = /<head(?:\s[^>]*)?>/i.exec(html);
31
+ const head = /<head(?:\s[^<>]*)?>/i.exec(html);
31
32
  if (!head) return `${script}\n${html}`;
32
33
  const at = head.index + head[0].length;
33
34
  return `${html.slice(0, at)}\n${script}${html.slice(at)}`;
@@ -45,6 +45,7 @@ describe("UpdateUploader", () => {
45
45
  prefix: "acme/kiosk",
46
46
  });
47
47
  expect(UpdateUploader.parseTarget("s3://releases")).toEqual({ bucket: "releases", prefix: "" });
48
+ expect(UpdateUploader.parseTarget("s3://releases//acme//")).toEqual({ bucket: "releases", prefix: "/acme" });
48
49
  expect(() => UpdateUploader.parseTarget("https://releases.example/acme")).toThrow("s3://<bucket>/<prefix>");
49
50
  });
50
51