@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.
- 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/artifact/implicitRootLayout.test.ts +2 -0
- package/artifact/implicitRootLayout.ts +1 -0
- package/biome.base.json +3 -12
- package/biomeBase.ts +1 -1
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.ts +3 -2
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +128 -29
- package/codeAgent/tools/akanCodePlugins.test.ts +143 -16
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +52 -14
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +130 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- 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 +108 -3
- package/executors.ts +30 -13
- 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/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- 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/nativeApp.test.ts +1 -1
- 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 +20 -20
- package/piDependencyPins.test.ts +18 -19
- 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 +37 -4
- 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
package/executors.ts
CHANGED
|
@@ -54,7 +54,7 @@ import { Spinner } from "./spinner";
|
|
|
54
54
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
55
55
|
import type { TypeChecker } from "./typeChecker";
|
|
56
56
|
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
57
|
-
import type { FileContent, PackageJson, TsConfigJson } from "./types";
|
|
57
|
+
import type { BaseDevEnv, FileContent, PackageJson, TsConfigJson } from "./types";
|
|
58
58
|
import { barrelFacetDirs } from "./workspaceLayout";
|
|
59
59
|
|
|
60
60
|
export interface PageRoot {
|
|
@@ -271,20 +271,20 @@ export class Executor {
|
|
|
271
271
|
const readPath = this.getPath(dirPath);
|
|
272
272
|
try {
|
|
273
273
|
const glob = new Bun.Glob("*");
|
|
274
|
-
return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false }));
|
|
274
|
+
return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false })).sort();
|
|
275
275
|
} catch {
|
|
276
276
|
return [];
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
async getAllFiles(pattern = "**/*", { cwd }: { cwd?: string } = {}): Promise<string[]> {
|
|
280
280
|
const glob = new Bun.Glob(pattern);
|
|
281
|
-
return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true }));
|
|
281
|
+
return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true })).sort();
|
|
282
282
|
}
|
|
283
283
|
async getFilesAndDirs(dirPath: string): Promise<{ files: string[]; dirs: string[] }> {
|
|
284
284
|
const fullDirPath = this.getPath(dirPath);
|
|
285
285
|
const glob = new Bun.Glob("*");
|
|
286
|
-
const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true }));
|
|
287
|
-
const allEntries = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: false }));
|
|
286
|
+
const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true })).sort();
|
|
287
|
+
const allEntries = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: false })).sort();
|
|
288
288
|
const dirs = allEntries.filter((entry) => !files.includes(entry));
|
|
289
289
|
return { files, dirs };
|
|
290
290
|
}
|
|
@@ -662,6 +662,7 @@ export class WorkspaceExecutor extends Executor {
|
|
|
662
662
|
}
|
|
663
663
|
/** The public env a native build was told to reproduce (`--public-env`): it outranks `.env`, which outranks the rest. */
|
|
664
664
|
static publicEnvPin: Record<string, string> | null = null;
|
|
665
|
+
static readonly localServeDomain = "localhost";
|
|
665
666
|
static getBaseDevEnv(envPath?: string) {
|
|
666
667
|
// Bun auto-loads .env, so we use process.env directly
|
|
667
668
|
const sourceEnv = {
|
|
@@ -673,11 +674,11 @@ export class WorkspaceExecutor extends Executor {
|
|
|
673
674
|
const workspaceRoot = sourceEnv.AKAN_WORKSPACE_ROOT;
|
|
674
675
|
const workspaceId = sourceEnv.AKAN_WORKSPACE_ID;
|
|
675
676
|
|
|
676
|
-
const repoName =
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const serveDomain = sourceEnv.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
680
|
-
|
|
677
|
+
const repoName =
|
|
678
|
+
sourceEnv.AKAN_PUBLIC_REPO_NAME ||
|
|
679
|
+
resolveRepoName(envPath ? path.dirname(envPath) : (workspaceRoot ?? process.cwd()));
|
|
680
|
+
const serveDomain = sourceEnv.AKAN_PUBLIC_SERVE_DOMAIN || WorkspaceExecutor.localServeDomain;
|
|
681
|
+
const defaulted = (["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const).filter((key) => !sourceEnv[key]);
|
|
681
682
|
|
|
682
683
|
const portOffset = parseInt(sourceEnv.PORT_OFFSET ?? "0");
|
|
683
684
|
|
|
@@ -697,8 +698,20 @@ export class WorkspaceExecutor extends Executor {
|
|
|
697
698
|
env,
|
|
698
699
|
portOffset,
|
|
699
700
|
workspaceId,
|
|
701
|
+
defaulted,
|
|
700
702
|
};
|
|
701
703
|
}
|
|
704
|
+
//? A build or tunnel for a real environment carries these into an image, a bundle or a host name it dials.
|
|
705
|
+
static assertDeployableEnv(baseDevEnv: BaseDevEnv, environment: string, purpose: string) {
|
|
706
|
+
const defaulted = environment === "local" ? [] : (baseDevEnv.defaulted ?? []);
|
|
707
|
+
if (!defaulted.length) return;
|
|
708
|
+
const values = { AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName, AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain };
|
|
709
|
+
const standIns = defaulted.map((key) => `${key} (would be "${values[key]}")`).join(" and ");
|
|
710
|
+
throw new Error(
|
|
711
|
+
`${purpose} for the "${environment}" environment needs ${standIns} set, not a local default. ` +
|
|
712
|
+
`Set ${defaulted.length > 1 ? "them" : "it"} in the workspace .env or the shell, or use AKAN_PUBLIC_ENV=local.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
702
715
|
getWorkspaceId<AllowEmpty extends boolean = false>({
|
|
703
716
|
allowEmpty,
|
|
704
717
|
}: {
|
|
@@ -1159,10 +1172,12 @@ export class AppExecutor extends SysExecutor {
|
|
|
1159
1172
|
return basePort + appIndex + portOffset;
|
|
1160
1173
|
}
|
|
1161
1174
|
getCommandEnv(env: Record<string, string> = {}): Record<string, string> {
|
|
1162
|
-
const { portOffset } = WorkspaceExecutor.getBaseDevEnv();
|
|
1175
|
+
const { portOffset, repoName, serveDomain } = WorkspaceExecutor.getBaseDevEnv();
|
|
1163
1176
|
const PORT = (8282 + portOffset).toString();
|
|
1164
1177
|
return {
|
|
1165
1178
|
...process.env,
|
|
1179
|
+
AKAN_PUBLIC_REPO_NAME: repoName,
|
|
1180
|
+
AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
|
|
1166
1181
|
AKAN_PUBLIC_APP_NAME: this.name,
|
|
1167
1182
|
AKAN_WORKSPACE_ROOT: this.workspace.workspaceRoot,
|
|
1168
1183
|
NODE_NO_WARNINGS: "1",
|
|
@@ -1323,7 +1338,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1323
1338
|
pageKeys.push(key);
|
|
1324
1339
|
}
|
|
1325
1340
|
}
|
|
1326
|
-
pageKeys.sort();
|
|
1341
|
+
pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1327
1342
|
this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
|
|
1328
1343
|
return this.#pageKeys;
|
|
1329
1344
|
}
|
|
@@ -1581,7 +1596,9 @@ export class PkgExecutor extends Executor {
|
|
|
1581
1596
|
devDependencies: string[] = [],
|
|
1582
1597
|
): Promise<Pick<PackageJson, "dependencies" | "devDependencies">> {
|
|
1583
1598
|
const dependencyNames = [...new Set(dependencies)].sort();
|
|
1584
|
-
const devDependencyNames = [...new Set(devDependencies)]
|
|
1599
|
+
const devDependencyNames = [...new Set(devDependencies)]
|
|
1600
|
+
.filter((dep) => !dependencyNames.includes(dep))
|
|
1601
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1585
1602
|
const dependencyVersions = new Map<string, string>();
|
|
1586
1603
|
const missingDeps: string[] = [];
|
|
1587
1604
|
for (const dep of [...dependencyNames, ...devDependencyNames]) {
|
|
@@ -253,7 +253,9 @@ describe("AutoImportSync.syncForBatch", () => {
|
|
|
253
253
|
|
|
254
254
|
const result = await sync.syncForBatch([uiFile, pageFile, webkitFile]);
|
|
255
255
|
expect(result.errors).toEqual([]);
|
|
256
|
-
expect(result.changedFiles.sort(
|
|
256
|
+
expect(result.changedFiles.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(
|
|
257
|
+
[pageFile, webkitFile, uiFile].sort(),
|
|
258
|
+
);
|
|
257
259
|
expect(await readFile(uiFile, "utf8")).toContain(`import { st } from "@apps/akan/client";`);
|
|
258
260
|
expect(await readFile(pageFile, "utf8")).toContain(`import { cnst } from "@apps/akan/client";`);
|
|
259
261
|
expect(await readFile(webkitFile, "utf8")).toContain(`import { fetch } from "@libs/shared/client";`);
|
|
@@ -6,7 +6,7 @@ import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
|
|
|
6
6
|
import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
|
|
7
7
|
import type { AkanConfig, ClientEntryDiscovery, ScannedImport } from "./clientBuildTypes";
|
|
8
8
|
|
|
9
|
-
const USE_CLIENT_RE = /^\s*(?:\/\*[
|
|
9
|
+
const USE_CLIENT_RE = /^\s*(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
|
|
10
10
|
const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
|
|
11
11
|
const AKANJS_NODE_MODULE_RE = /[\\/]node_modules[\\/]akanjs[\\/]/;
|
|
12
12
|
// Bun's scanImports also surfaces CSS and asset imports, none of which can be a client component.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { tempDirs, writeText } from "../testHelpers";
|
|
4
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
4
5
|
|
|
5
6
|
const makeTempRoot = tempDirs("akan-csr-artifact-");
|
|
6
7
|
|
|
@@ -31,4 +32,33 @@ console.info(await result.outputs[0].text());
|
|
|
31
32
|
expect(stdout).toContain("envmark:debug");
|
|
32
33
|
expect(stdout).not.toContain("envmark:local");
|
|
33
34
|
});
|
|
35
|
+
|
|
36
|
+
//? An OTA release is keyed on the bundle's hash, so a no-op build from another checkout must not stage one.
|
|
37
|
+
test("bundles the same app identically from two checkout paths", async () => {
|
|
38
|
+
const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: "main" }) };
|
|
39
|
+
const buildFrom = async (root: string) => {
|
|
40
|
+
await writeText(
|
|
41
|
+
path.join(root, "node_modules/wasmdep/package.json"),
|
|
42
|
+
'{ "name": "wasmdep", "main": "index.js" }\n',
|
|
43
|
+
);
|
|
44
|
+
await writeText(
|
|
45
|
+
path.join(root, "node_modules/wasmdep/index.js"),
|
|
46
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: the fixture module's own source holds a template literal
|
|
47
|
+
'var isNode = typeof process == "object" && !!process.versions?.node;\nmodule.exports = isNode ? `${__dirname}/` : "";\n',
|
|
48
|
+
);
|
|
49
|
+
await writeText(path.join(root, "entry.ts"), 'import dir from "wasmdep";\nconsole.info(dir);\n');
|
|
50
|
+
const result = await Bun.build({
|
|
51
|
+
...CsrArtifactBuilder.bundleOptions(app as never, "build"),
|
|
52
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
53
|
+
});
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
return await result.outputs[0]?.text();
|
|
56
|
+
};
|
|
57
|
+
const [first, second] = [await makeTempRoot(), await makeTempRoot()];
|
|
58
|
+
|
|
59
|
+
const [firstOutput, secondOutput] = [await buildFrom(first), await buildFrom(second)];
|
|
60
|
+
|
|
61
|
+
expect(firstOutput).toBe(secondOutput);
|
|
62
|
+
expect(firstOutput).not.toContain(path.basename(first));
|
|
63
|
+
});
|
|
34
64
|
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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 =
|
|
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
|
|
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" },
|