@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.11
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.test.ts +51 -5
- package/akanConfig/akanConfig.ts +33 -14
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +57 -2
- package/applicationBuildRunner.ts +15 -6
- package/artifact/implicitRootLayout.test.ts +2 -0
- package/artifact/implicitRootLayout.ts +1 -0
- package/biome.base.json +1 -11
- package/biomeBase.ts +1 -1
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +9 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- 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/argMeta.ts +11 -2
- package/commandDecorators/command.ts +45 -10
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -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 +131 -3
- package/executors.ts +56 -36
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +23 -4
- 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/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- 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/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/mobileTarget.ts +1 -2
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +5 -3
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +115 -22
- package/mobile/updateUploader.ts +77 -10
- package/package.json +20 -20
- package/piDependencyPins.test.ts +18 -19
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.test.ts +5 -3
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +1 -1
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +362 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- 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/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/transforms/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/typecheck/projectTypecheck.ts +6 -5
- 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
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
isRouteSourceFile,
|
|
29
29
|
Logger,
|
|
30
30
|
parseRouteModuleKey,
|
|
31
|
+
toError,
|
|
31
32
|
validatePageSourceFile,
|
|
32
33
|
validateSubRoutePageKey,
|
|
33
34
|
} from "akanjs/common";
|
|
@@ -52,7 +53,8 @@ import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } f
|
|
|
52
53
|
import { Spinner } from "./spinner";
|
|
53
54
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
54
55
|
import type { TypeChecker } from "./typeChecker";
|
|
55
|
-
import
|
|
56
|
+
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
57
|
+
import type { BaseDevEnv, FileContent, PackageJson, TsConfigJson } from "./types";
|
|
56
58
|
import { barrelFacetDirs } from "./workspaceLayout";
|
|
57
59
|
|
|
58
60
|
export interface PageRoot {
|
|
@@ -64,6 +66,10 @@ export interface PageRoot {
|
|
|
64
66
|
keyPrefix: string;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
export interface WrittenFileContent extends FileContent {
|
|
70
|
+
written: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
const staticTemplateFileExtensions = new Set(
|
|
68
74
|
".avif .bmp .cjs .css .eot .gif .html .ico .jpeg .jpg .js .json .map .md .mjs .mp3 .mp4 .ogg .otf .pdf .png .svg"
|
|
69
75
|
.split(" ")
|
|
@@ -265,20 +271,20 @@ export class Executor {
|
|
|
265
271
|
const readPath = this.getPath(dirPath);
|
|
266
272
|
try {
|
|
267
273
|
const glob = new Bun.Glob("*");
|
|
268
|
-
return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false }));
|
|
274
|
+
return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false })).sort();
|
|
269
275
|
} catch {
|
|
270
276
|
return [];
|
|
271
277
|
}
|
|
272
278
|
}
|
|
273
279
|
async getAllFiles(pattern = "**/*", { cwd }: { cwd?: string } = {}): Promise<string[]> {
|
|
274
280
|
const glob = new Bun.Glob(pattern);
|
|
275
|
-
return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true }));
|
|
281
|
+
return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true })).sort();
|
|
276
282
|
}
|
|
277
283
|
async getFilesAndDirs(dirPath: string): Promise<{ files: string[]; dirs: string[] }> {
|
|
278
284
|
const fullDirPath = this.getPath(dirPath);
|
|
279
285
|
const glob = new Bun.Glob("*");
|
|
280
|
-
const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true }));
|
|
281
|
-
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();
|
|
282
288
|
const dirs = allEntries.filter((entry) => !files.includes(entry));
|
|
283
289
|
return { files, dirs };
|
|
284
290
|
}
|
|
@@ -302,17 +308,19 @@ export class Executor {
|
|
|
302
308
|
filePath: string,
|
|
303
309
|
content: string | object,
|
|
304
310
|
{ overwrite = true, silent = false }: { overwrite?: boolean; silent?: boolean } = {},
|
|
305
|
-
): Promise<
|
|
311
|
+
): Promise<WrittenFileContent> {
|
|
306
312
|
const writePath = this.getPath(filePath);
|
|
307
313
|
const dir = path.dirname(writePath);
|
|
308
314
|
if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
|
|
309
315
|
let contentStr = typeof content === "string" ? content : JsonFormatter.stringify(content, writePath);
|
|
316
|
+
let written = true;
|
|
310
317
|
|
|
311
318
|
if (await FileSys.fileExists(writePath)) {
|
|
312
319
|
const currentContent = await FileSys.readText(writePath);
|
|
313
320
|
if (currentContent === contentStr || !overwrite) {
|
|
314
321
|
this.logger.verbose(`File ${writePath} is unchanged`);
|
|
315
322
|
contentStr = currentContent;
|
|
323
|
+
written = false;
|
|
316
324
|
} else {
|
|
317
325
|
await FileSys.writeText(writePath, contentStr);
|
|
318
326
|
if (Logger.isVerbose()) this.logger.rawLog(chalk.yellow(`File Update: ${filePath}`));
|
|
@@ -321,7 +329,7 @@ export class Executor {
|
|
|
321
329
|
await FileSys.writeText(writePath, contentStr);
|
|
322
330
|
if (!silent) this.logger.rawLog(chalk.green(`File Create: ${filePath}`));
|
|
323
331
|
}
|
|
324
|
-
return { filePath: writePath, content: contentStr };
|
|
332
|
+
return { filePath: writePath, content: contentStr, written };
|
|
325
333
|
}
|
|
326
334
|
async writeJson(filePath: string, content: object) {
|
|
327
335
|
await this.writeFile(filePath, content);
|
|
@@ -418,7 +426,7 @@ export class Executor {
|
|
|
418
426
|
},
|
|
419
427
|
dict: { [key: string]: string } = {},
|
|
420
428
|
options: { [key: string]: unknown } = {},
|
|
421
|
-
): Promise<
|
|
429
|
+
): Promise<WrittenFileContent | null> {
|
|
422
430
|
const convertPath = (target: string) =>
|
|
423
431
|
Object.entries(dict).reduce(
|
|
424
432
|
(converted, [key, value]) => converted.replace(new RegExp(`__${key}__`, "g"), value),
|
|
@@ -451,11 +459,13 @@ export class Executor {
|
|
|
451
459
|
} else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
|
|
452
460
|
const convertedTargetPath = convertPath(targetPath);
|
|
453
461
|
const writePath = this.getPath(convertedTargetPath);
|
|
462
|
+
if (!overwrite && (await FileSys.fileExists(writePath)))
|
|
463
|
+
return { filePath: writePath, content: "", written: false };
|
|
454
464
|
const dirname = path.dirname(writePath);
|
|
455
465
|
if (!(await FileSys.dirExists(dirname))) await mkdir(dirname, { recursive: true });
|
|
456
466
|
await copyFile(templatePath, writePath);
|
|
457
467
|
this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
|
|
458
|
-
return { filePath: writePath, content: "" };
|
|
468
|
+
return { filePath: writePath, content: "", written: true };
|
|
459
469
|
} else return null;
|
|
460
470
|
}
|
|
461
471
|
async _applyTemplate({
|
|
@@ -472,7 +482,7 @@ export class Executor {
|
|
|
472
482
|
dict?: { [key: string]: string };
|
|
473
483
|
options?: { [key: string]: unknown };
|
|
474
484
|
overwrite?: boolean;
|
|
475
|
-
}): Promise<
|
|
485
|
+
}): Promise<WrittenFileContent[]> {
|
|
476
486
|
const templateRoot = await this.#resolveTemplateRoot();
|
|
477
487
|
const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
|
|
478
488
|
if ((await stat(templatePath)).isFile()) {
|
|
@@ -486,7 +496,7 @@ export class Executor {
|
|
|
486
496
|
dict,
|
|
487
497
|
options,
|
|
488
498
|
);
|
|
489
|
-
return fileContent ? [fileContent] : ([] as
|
|
499
|
+
return fileContent ? [fileContent] : ([] as WrittenFileContent[]);
|
|
490
500
|
} else {
|
|
491
501
|
const subdirs = await readDirEntries(templatePath);
|
|
492
502
|
const fileContents = (
|
|
@@ -504,7 +514,7 @@ export class Executor {
|
|
|
504
514
|
dict,
|
|
505
515
|
options,
|
|
506
516
|
);
|
|
507
|
-
return fileContent ? [fileContent] : ([] as
|
|
517
|
+
return fileContent ? [fileContent] : ([] as WrittenFileContent[]);
|
|
508
518
|
} else
|
|
509
519
|
return await this._applyTemplate({
|
|
510
520
|
basePath: path.join(basePath, subdir),
|
|
@@ -539,7 +549,7 @@ export class Executor {
|
|
|
539
549
|
overwrite?: boolean;
|
|
540
550
|
}): Promise<FileContent[]> {
|
|
541
551
|
const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
|
|
542
|
-
await this.#formatAppliedTemplate(fileContents);
|
|
552
|
+
await this.#formatAppliedTemplate(fileContents.filter((fileContent) => fileContent.written));
|
|
543
553
|
return fileContents;
|
|
544
554
|
}
|
|
545
555
|
|
|
@@ -554,7 +564,7 @@ export class Executor {
|
|
|
554
564
|
const { fixed } = await this.getLinter().fixFiles(filePaths);
|
|
555
565
|
if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
|
|
556
566
|
} catch (err) {
|
|
557
|
-
this.logger.verbose(`Skipped formatting scaffolded files: ${err
|
|
567
|
+
this.logger.verbose(`Skipped formatting scaffolded files: ${toError(err).message}`);
|
|
558
568
|
}
|
|
559
569
|
}
|
|
560
570
|
// Async so `typescript` (~65MB resident) loads only in the commands that typecheck.
|
|
@@ -572,7 +582,7 @@ export class Executor {
|
|
|
572
582
|
}
|
|
573
583
|
async typeCheckAsync(filePath: string) {
|
|
574
584
|
const path = this.getPath(filePath);
|
|
575
|
-
const entry = await
|
|
585
|
+
const entry = await ProjectTypecheck.workerEntry(process.cwd());
|
|
576
586
|
const proc = Bun.spawn([process.execPath, entry], {
|
|
577
587
|
cwd: this.cwdPath,
|
|
578
588
|
env: {
|
|
@@ -603,18 +613,6 @@ export class Executor {
|
|
|
603
613
|
message: result.message,
|
|
604
614
|
};
|
|
605
615
|
}
|
|
606
|
-
async #resolveTypecheckWorkerEntry() {
|
|
607
|
-
const dirname = getDirname(import.meta.url);
|
|
608
|
-
const candidates = [
|
|
609
|
-
path.join(process.cwd(), "pkgs/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
610
|
-
path.join(process.cwd(), "node_modules/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
611
|
-
path.join(dirname, "typecheck/typecheck.proc.ts"),
|
|
612
|
-
path.join(dirname, "typecheck.proc.js"),
|
|
613
|
-
path.join(dirname, "typecheck.proc.ts"),
|
|
614
|
-
];
|
|
615
|
-
for (const candidate of candidates) if (await Bun.file(candidate).exists()) return candidate;
|
|
616
|
-
throw new Error(`[devkit] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
617
|
-
}
|
|
618
616
|
getLinter() {
|
|
619
617
|
this.linter ??= new Linter(this.cwdPath);
|
|
620
618
|
return this.linter;
|
|
@@ -662,19 +660,25 @@ export class WorkspaceExecutor extends Executor {
|
|
|
662
660
|
} = {}) {
|
|
663
661
|
return new WorkspaceExecutor({ workspaceRoot, repoName });
|
|
664
662
|
}
|
|
663
|
+
/** The public env a native build was told to reproduce (`--public-env`): it outranks `.env`, which outranks the rest. */
|
|
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
|
-
const sourceEnv =
|
|
668
|
+
const sourceEnv = {
|
|
669
|
+
...(envPath ? { ...process.env, ...parseEnvFile(envPath) } : process.env),
|
|
670
|
+
...WorkspaceExecutor.publicEnvPin,
|
|
671
|
+
};
|
|
668
672
|
|
|
669
673
|
const appName = sourceEnv.AKAN_PUBLIC_APP_NAME;
|
|
670
674
|
const workspaceRoot = sourceEnv.AKAN_WORKSPACE_ROOT;
|
|
671
675
|
const workspaceId = sourceEnv.AKAN_WORKSPACE_ID;
|
|
672
676
|
|
|
673
|
-
const repoName =
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const serveDomain = sourceEnv.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
677
|
-
|
|
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]);
|
|
678
682
|
|
|
679
683
|
const portOffset = parseInt(sourceEnv.PORT_OFFSET ?? "0");
|
|
680
684
|
|
|
@@ -694,8 +698,20 @@ export class WorkspaceExecutor extends Executor {
|
|
|
694
698
|
env,
|
|
695
699
|
portOffset,
|
|
696
700
|
workspaceId,
|
|
701
|
+
defaulted,
|
|
697
702
|
};
|
|
698
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
|
+
}
|
|
699
715
|
getWorkspaceId<AllowEmpty extends boolean = false>({
|
|
700
716
|
allowEmpty,
|
|
701
717
|
}: {
|
|
@@ -1156,10 +1172,12 @@ export class AppExecutor extends SysExecutor {
|
|
|
1156
1172
|
return basePort + appIndex + portOffset;
|
|
1157
1173
|
}
|
|
1158
1174
|
getCommandEnv(env: Record<string, string> = {}): Record<string, string> {
|
|
1159
|
-
const { portOffset } = WorkspaceExecutor.getBaseDevEnv();
|
|
1175
|
+
const { portOffset, repoName, serveDomain } = WorkspaceExecutor.getBaseDevEnv();
|
|
1160
1176
|
const PORT = (8282 + portOffset).toString();
|
|
1161
1177
|
return {
|
|
1162
1178
|
...process.env,
|
|
1179
|
+
AKAN_PUBLIC_REPO_NAME: repoName,
|
|
1180
|
+
AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
|
|
1163
1181
|
AKAN_PUBLIC_APP_NAME: this.name,
|
|
1164
1182
|
AKAN_WORKSPACE_ROOT: this.workspace.workspaceRoot,
|
|
1165
1183
|
NODE_NO_WARNINGS: "1",
|
|
@@ -1320,7 +1338,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1320
1338
|
pageKeys.push(key);
|
|
1321
1339
|
}
|
|
1322
1340
|
}
|
|
1323
|
-
pageKeys.sort();
|
|
1341
|
+
pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1324
1342
|
this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
|
|
1325
1343
|
return this.#pageKeys;
|
|
1326
1344
|
}
|
|
@@ -1578,7 +1596,9 @@ export class PkgExecutor extends Executor {
|
|
|
1578
1596
|
devDependencies: string[] = [],
|
|
1579
1597
|
): Promise<Pick<PackageJson, "dependencies" | "devDependencies">> {
|
|
1580
1598
|
const dependencyNames = [...new Set(dependencies)].sort();
|
|
1581
|
-
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));
|
|
1582
1602
|
const dependencyVersions = new Map<string, string>();
|
|
1583
1603
|
const missingDeps: string[] = [];
|
|
1584
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";
|
|
@@ -61,8 +62,9 @@ export class CsrArtifactBuilder {
|
|
|
61
62
|
).flat(),
|
|
62
63
|
);
|
|
63
64
|
|
|
65
|
+
const options = CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports);
|
|
64
66
|
const result = await Bun.build({
|
|
65
|
-
...
|
|
67
|
+
...options,
|
|
66
68
|
entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
|
|
67
69
|
files: generatedFiles,
|
|
68
70
|
root: this.#generatedDir,
|
|
@@ -75,6 +77,7 @@ export class CsrArtifactBuilder {
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
await this.#inlineCsrArtifacts(cssAssets);
|
|
80
|
+
if (this.#command === "build") await CsrArtifactBuilder.writePublicEnv(this.#app, options.define);
|
|
78
81
|
this.#app.verbose(`[csr-build] output -> ${this.#outputDir}`);
|
|
79
82
|
return { outputDir: this.#outputDir };
|
|
80
83
|
}
|
|
@@ -88,10 +91,25 @@ export class CsrArtifactBuilder {
|
|
|
88
91
|
define: bundleDefine(app, command, "csr"),
|
|
89
92
|
optimizeImports,
|
|
90
93
|
// A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
|
|
91
|
-
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
94
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin()],
|
|
92
95
|
};
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
/** Where a build records the `AKAN_PUBLIC_*` it inlined, which nothing in the minified bundle names. */
|
|
99
|
+
static publicEnvPath(app: App) {
|
|
100
|
+
return path.join(app.dist.cwdPath, ".akan", "artifact", "csr-public-env.json");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static async writePublicEnv(app: App, define: Record<string, string>) {
|
|
104
|
+
const prefix = "process.env.";
|
|
105
|
+
const publicEnv = Object.fromEntries(
|
|
106
|
+
Object.entries(define)
|
|
107
|
+
.filter(([key]) => key.startsWith(`${prefix}AKAN_PUBLIC_`))
|
|
108
|
+
.map(([key, value]) => [key.slice(prefix.length), JSON.parse(value) as unknown]),
|
|
109
|
+
);
|
|
110
|
+
await Bun.write(CsrArtifactBuilder.publicEnvPath(app), `${JSON.stringify(publicEnv, null, 2)}\n`);
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
/** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
|
|
96
114
|
static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
|
|
97
115
|
return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
|
|
@@ -217,7 +235,7 @@ void bootCsr(pages);
|
|
|
217
235
|
for await (const filePath of glob.scan({ cwd: this.#outputDir, absolute: true })) {
|
|
218
236
|
if (predicate(filePath)) files.push(filePath);
|
|
219
237
|
}
|
|
220
|
-
return files.sort();
|
|
238
|
+
return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
221
239
|
}
|
|
222
240
|
|
|
223
241
|
// Bun hoists the module script into <head> and a React bundle contains "</head>" as text, so positions come from a
|
|
@@ -231,9 +249,10 @@ void bootCsr(pages);
|
|
|
231
249
|
return `${html}\n${snippet}`;
|
|
232
250
|
}
|
|
233
251
|
|
|
252
|
+
// An unclosed body runs to EOF, as the HTML tokenizer reads it; that also keeps the scan linear.
|
|
234
253
|
static blankEmbeddedContent(html: string): string {
|
|
235
254
|
return html.replace(
|
|
236
|
-
/<script\b[^>]*>[\s\S]
|
|
255
|
+
/<script\b[^>]*>[\s\S]*?(?:<\/script(?:[\s/][^<>]*)?>|$)|<style\b[^>]*>[\s\S]*?(?:<\/style(?:[\s/][^<>]*)?>|$)|<!--[\s\S]*?(?:--!?>|$)/gi,
|
|
237
256
|
(match) => " ".repeat(match.length),
|
|
238
257
|
);
|
|
239
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
|
+
}
|