@akanjs/devkit 3.1.0-rc.4 → 3.1.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/abstractDoc.ts +1 -1
- package/agentsIndex.test.ts +4 -1
- package/akanConfig/akanConfig.ts +2 -2
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +24 -1
- package/applicationBuildRunner.ts +11 -1
- package/biome.base.json +0 -10
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.ts +3 -2
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/tools/WebToolPack.ts +4 -4
- package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
- package/commandDecorators/command.ts +5 -4
- package/createTunnel.test.ts +14 -0
- package/createTunnel.ts +3 -1
- package/dependencyScanner.ts +1 -1
- package/devkitUtils.test.ts +1 -1
- package/executors.test.ts +62 -3
- package/executors.ts +26 -9
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +5 -3
- package/frontendBuild/csrDevPatcher.test.ts +4 -1
- package/frontendBuild/cssBlockParser.test.ts +123 -0
- package/frontendBuild/cssBlockParser.ts +132 -0
- package/frontendBuild/cssCompiler.test.ts +53 -0
- package/frontendBuild/cssCompiler.ts +8 -4
- package/frontendBuild/fontOptimizer.ts +2 -1
- package/frontendBuild/frontendBuild.test.ts +14 -0
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
- package/frontendBuild/pagesBundleBuilder.ts +64 -9
- package/frontendBuild/routeClientBuilder.ts +1 -1
- package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
- package/frontendBuild/themeValidator.test.ts +83 -2
- package/frontendBuild/themeValidator.ts +37 -15
- package/getRelatedCnsts.ts +2 -1
- package/integration/devStabilityHarness.ts +5 -3
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
- package/mobile/mobileTarget.ts +1 -2
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +3 -2
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/updateUploader.test.ts +1 -0
- package/mobile/updateUploader.ts +3 -3
- package/package.json +2 -2
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +1 -1
- package/subspaceClone.ts +5 -4
- package/testHelpers.ts +2 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/browserDirnamePlugin.test.ts +88 -0
- package/transforms/browserDirnamePlugin.ts +25 -0
- package/transforms/rscUseClientTransform.ts +2 -2
- package/transforms/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +1 -1
- package/workflow/rolloutGate.test.ts +5 -1
- package/workflow/source.ts +2 -2
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import type { BaseBuildArtifact } from "akanjs/server";
|
|
4
4
|
import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
|
+
import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
|
|
6
7
|
import { bundleDefine } from "./bundleDefine";
|
|
7
8
|
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
8
9
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
@@ -90,7 +91,7 @@ export class CsrArtifactBuilder {
|
|
|
90
91
|
define: bundleDefine(app, command, "csr"),
|
|
91
92
|
optimizeImports,
|
|
92
93
|
// A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
|
|
93
|
-
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
94
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin()],
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
97
|
|
|
@@ -234,7 +235,7 @@ void bootCsr(pages);
|
|
|
234
235
|
for await (const filePath of glob.scan({ cwd: this.#outputDir, absolute: true })) {
|
|
235
236
|
if (predicate(filePath)) files.push(filePath);
|
|
236
237
|
}
|
|
237
|
-
return files.sort();
|
|
238
|
+
return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
// Bun hoists the module script into <head> and a React bundle contains "</head>" as text, so positions come from a
|
|
@@ -248,9 +249,10 @@ void bootCsr(pages);
|
|
|
248
249
|
return `${html}\n${snippet}`;
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
// An unclosed body runs to EOF, as the HTML tokenizer reads it; that also keeps the scan linear.
|
|
251
253
|
static blankEmbeddedContent(html: string): string {
|
|
252
254
|
return html.replace(
|
|
253
|
-
/<script\b[^>]*>[\s\S]
|
|
255
|
+
/<script\b[^>]*>[\s\S]*?(?:<\/script(?:[\s/][^<>]*)?>|$)|<style\b[^>]*>[\s\S]*?(?:<\/style(?:[\s/][^<>]*)?>|$)|<!--[\s\S]*?(?:--!?>|$)/gi,
|
|
254
256
|
(match) => " ".repeat(match.length),
|
|
255
257
|
);
|
|
256
258
|
}
|
|
@@ -379,7 +379,10 @@ describe("CsrDevPatcher", () => {
|
|
|
379
379
|
await expect(patcher.update([], { roots: [file("ui/E1.tsx")], onlyRoots: true })).rejects.toThrow();
|
|
380
380
|
await expect(patcher.update([], { roots: [file("ui/E4.tsx")], onlyRoots: true })).rejects.toThrow();
|
|
381
381
|
const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
|
|
382
|
-
expect(graph.pending.sort()).toEqual([
|
|
382
|
+
expect(graph.pending.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
383
|
+
"apps/demo/ui/E1.tsx",
|
|
384
|
+
"apps/demo/ui/E4.tsx",
|
|
385
|
+
]);
|
|
383
386
|
});
|
|
384
387
|
|
|
385
388
|
test("a route's check that adds its own root leaves another route's failed root pending", async () => {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type CssBlock, CssBlockParser, type CssNode } from "./cssBlockParser";
|
|
3
|
+
|
|
4
|
+
const blocksOf = (block: CssBlock) => block.nodes.filter((node): node is CssBlock => node.kind === "block");
|
|
5
|
+
const declarationsOf = (block: CssBlock) =>
|
|
6
|
+
block.nodes.flatMap((node) => (node.kind === "declaration" ? [[node.property, node.value]] : []));
|
|
7
|
+
|
|
8
|
+
const expectLinear = (run: () => unknown) => {
|
|
9
|
+
const started = performance.now();
|
|
10
|
+
run();
|
|
11
|
+
expect(performance.now() - started).toBeLessThan(1000);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("CssBlockParser.parse", () => {
|
|
15
|
+
test("reads consecutive blocks with their preludes and declarations", () => {
|
|
16
|
+
const root = CssBlockParser.parse(`a { --x: 1; } b { --y: 2 } c{--z:3}`);
|
|
17
|
+
expect(blocksOf(root).map((block) => block.prelude)).toEqual(["a", "b", "c"]);
|
|
18
|
+
expect(blocksOf(root).map(declarationsOf)).toEqual([[["--x", "1"]], [["--y", "2"]], [["--z", "3"]]]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("nests at-rules and nested rules, keeping a block's own declarations apart from its children's", () => {
|
|
22
|
+
const root = CssBlockParser.parse(
|
|
23
|
+
`@media (min-width: 1px) { :root { --a: 1; } } .card { color: red; &:hover { color: blue; } --b: 2; }`,
|
|
24
|
+
);
|
|
25
|
+
const [media, card] = blocksOf(root);
|
|
26
|
+
expect(media.atRule).toBe("@media");
|
|
27
|
+
expect(media.prelude).toBe("@media (min-width: 1px)");
|
|
28
|
+
expect(declarationsOf(blocksOf(media)[0])).toEqual([["--a", "1"]]);
|
|
29
|
+
expect(card.atRule).toBeNull();
|
|
30
|
+
expect(declarationsOf(card)).toEqual([
|
|
31
|
+
["color", "red"],
|
|
32
|
+
["--b", "2"],
|
|
33
|
+
]);
|
|
34
|
+
expect(blocksOf(card).map((block) => block.prelude)).toEqual(["&:hover"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("treats at-rule statements as neither blocks nor declarations", () => {
|
|
38
|
+
const root = CssBlockParser.parse(
|
|
39
|
+
`@source "./**/*";\n@import "tailwindcss";\n@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));\n:root { --a: 1; }`,
|
|
40
|
+
);
|
|
41
|
+
expect(root.nodes).toEqual([
|
|
42
|
+
{ kind: "block", prelude: ":root", atRule: null, nodes: [{ kind: "declaration", property: "--a", value: "1" }] },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("ignores braces, semicolons and comment markers inside strings, comments and escapes", () => {
|
|
47
|
+
const root = CssBlockParser.parse(
|
|
48
|
+
[
|
|
49
|
+
`/* :root { --commented: 1; } */`,
|
|
50
|
+
`.a::before { content: "} ; /* {"; --q: 'x;y'; }`,
|
|
51
|
+
`.w-\\[10px\\]\\{ { --escaped: 1; }`,
|
|
52
|
+
`.b { background: url(data:image/png;base64,AAAA); --after: 2; }`,
|
|
53
|
+
].join("\n"),
|
|
54
|
+
);
|
|
55
|
+
expect(blocksOf(root).map((block) => block.prelude)).toEqual([".a::before", ".w-\\[10px\\]\\{", ".b"]);
|
|
56
|
+
expect(blocksOf(root).map(declarationsOf)).toEqual([
|
|
57
|
+
[
|
|
58
|
+
["content", `"} ; /* {"`],
|
|
59
|
+
["--q", "'x;y'"],
|
|
60
|
+
],
|
|
61
|
+
[["--escaped", "1"]],
|
|
62
|
+
[
|
|
63
|
+
["background", "url(data:image/png;base64,AAAA)"],
|
|
64
|
+
["--after", "2"],
|
|
65
|
+
],
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("drops comments from preludes and values", () => {
|
|
70
|
+
const root = CssBlockParser.parse(`/* lead */ :root /* mid */ , [data-theme="dark"] { --a: /* x */ #fff; }`);
|
|
71
|
+
const [block] = blocksOf(root);
|
|
72
|
+
expect(block.prelude).toBe(`:root , [data-theme="dark"]`);
|
|
73
|
+
expect(declarationsOf(block)).toEqual([["--a", "#fff"]]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("closes what the input leaves open and tolerates a stray closing brace", () => {
|
|
77
|
+
const root = CssBlockParser.parse(`} :root { --a: 1; .x { --b: 2`);
|
|
78
|
+
const [rootBlock] = blocksOf(root);
|
|
79
|
+
expect(declarationsOf(rootBlock)).toEqual([["--a", "1"]]);
|
|
80
|
+
expect(declarationsOf(blocksOf(rootBlock)[0])).toEqual([["--b", "2"]]);
|
|
81
|
+
expect(CssBlockParser.parse(`.a { content: "unterminated\n--b: 2; }`).nodes).toHaveLength(1);
|
|
82
|
+
expect(CssBlockParser.parse(`.a { --b: 1 } /* unterminated`).nodes).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("CssBlockParser.walk", () => {
|
|
87
|
+
test("visits in source order, passing each block's context to its children", () => {
|
|
88
|
+
const root = CssBlockParser.parse(
|
|
89
|
+
`.a { --x: 1; @media m { --y: 2; } --z: 3; } @theme { --skipped: 1; } .b { --w: 4 }`,
|
|
90
|
+
);
|
|
91
|
+
const visited: string[] = [];
|
|
92
|
+
CssBlockParser.walk(root, "", (node: CssNode, path: string) => {
|
|
93
|
+
if (node.kind === "declaration") {
|
|
94
|
+
visited.push(`${path}${node.property}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return node.atRule === "@theme" ? null : `${path}${node.prelude}>`;
|
|
98
|
+
});
|
|
99
|
+
expect(visited).toEqual([".a>--x", ".a>@media m>--y", ".a>--z", ".b>--w"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("walks a deeply nested tree without recursion", () => {
|
|
103
|
+
const root = CssBlockParser.parse(`${"@media x{".repeat(100_000)}:root{--deep:1}`);
|
|
104
|
+
let found = "";
|
|
105
|
+
CssBlockParser.walk(root, true, (node) => {
|
|
106
|
+
if (node.kind === "declaration") found = node.property;
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
expect(found).toBe("--deep");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("CssBlockParser linear time", () => {
|
|
114
|
+
test("adversarial runs finish well under a second", () => {
|
|
115
|
+
expectLinear(() => CssBlockParser.parse(`}${" ".repeat(100_000)}`));
|
|
116
|
+
expectLinear(() => CssBlockParser.parse(`:root{${"--".repeat(100_000)}`));
|
|
117
|
+
expectLinear(() => CssBlockParser.parse("@theme".repeat(100_000)));
|
|
118
|
+
expectLinear(() => CssBlockParser.parse("/*".repeat(100_000)));
|
|
119
|
+
expectLinear(() => CssBlockParser.parse(`"${"\\".repeat(100_000)}`));
|
|
120
|
+
expectLinear(() => CssBlockParser.parse("(".repeat(100_000)));
|
|
121
|
+
expectLinear(() => CssBlockParser.parse(":root{--a:#fff}".repeat(100_000)));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
391
|
-
|
|
392
|
-
(
|
|
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()
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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)(\
|
|
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 () => {
|