@akanjs/cli 2.3.11-rc.8 → 2.3.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.
@@ -2691,6 +2691,31 @@ function validateRouteSourceExports(source, filePath, kind, options = {}) {
2691
2691
  throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
2692
2692
  }
2693
2693
  }
2694
+ function validateOverridesSourceExports(source, filePath) {
2695
+ const sourceFile = ts3.createSourceFile(filePath, source, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TSX);
2696
+ const fail = (message) => {
2697
+ throw new Error(`[route-convention] ${message}: ${filePath}`);
2698
+ };
2699
+ let defaultOverride = null;
2700
+ for (const statement of sourceFile.statements) {
2701
+ if (ts3.isExpressionStatement(statement) && ts3.isStringLiteral(statement.expression))
2702
+ continue;
2703
+ if (ts3.isImportDeclaration(statement))
2704
+ continue;
2705
+ if (ts3.isInterfaceDeclaration(statement) || ts3.isTypeAliasDeclaration(statement))
2706
+ continue;
2707
+ if (ts3.isExportAssignment(statement) && !statement.isExportEquals) {
2708
+ defaultOverride = statement;
2709
+ continue;
2710
+ }
2711
+ fail(`_overrides.tsx may only contain imports and a single "export default override({ ... })"`);
2712
+ }
2713
+ if (!defaultOverride)
2714
+ fail(`_overrides.tsx must "export default override({ ... })"`);
2715
+ const expression = defaultOverride.expression;
2716
+ if (!ts3.isCallExpression(expression) || !ts3.isIdentifier(expression.expression) || expression.expression.text !== "override")
2717
+ fail(`_overrides.tsx default export must be a call to "override", e.g. "export default override({ Modal: BrandModal })"`);
2718
+ }
2694
2719
 
2695
2720
  class Executor {
2696
2721
  static verbose = false;
@@ -3714,7 +3739,11 @@ class AppExecutor extends SysExecutor {
3714
3739
  throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
3715
3740
  }
3716
3741
  const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
3717
- validateRouteSourceExports(await Bun.file(absPath).text(), absPath, parsed.kind, { rootLayout: isRootLayout });
3742
+ const routeSource = await Bun.file(absPath).text();
3743
+ if (parsed.kind === "overrides")
3744
+ validateOverridesSourceExports(routeSource, absPath);
3745
+ else
3746
+ validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
3718
3747
  pageKeys.push(key);
3719
3748
  }
3720
3749
  pageKeys.sort();
@@ -8822,6 +8851,25 @@ async function appHasStModule(appCwdPath) {
8822
8851
  }
8823
8852
  var IMPLICIT_LAYOUT_DIR = path13.join(".akan", "generated", "root-layouts");
8824
8853
  var IMPLICIT_DICT_DIR = path13.join(".akan", "generated", "dict");
8854
+ var IMPLICIT_OVERRIDES_DIR = path13.join(".akan", "generated", "overrides");
8855
+ var OVERRIDES_KEY_RE = /^\.\/(.+\/)?_overrides\.(tsx|ts|jsx|js)$/;
8856
+ async function writeGeneratedOverridesLayoutFile(opts) {
8857
+ const filename = `${opts.key.replace(/^\.\//, "").replace(/[^a-zA-Z0-9]+/g, "_")}.tsx`;
8858
+ const absPath = path13.join(path13.resolve(opts.appCwdPath), IMPLICIT_OVERRIDES_DIR, filename);
8859
+ const userRel = path13.relative(path13.dirname(absPath), opts.userAbsPath).split(path13.sep).join("/");
8860
+ const userSpecifier = userRel.startsWith(".") ? userRel : `./${userRel}`;
8861
+ const source2 = `"use client";
8862
+ import { UiOverrideProvider } from "akanjs/ui";
8863
+ import { createElement, type ReactNode } from "react";
8864
+ import value from ${JSON.stringify(userSpecifier)};
8865
+
8866
+ export default function AkanUiOverridesLayout({ children }: { children?: ReactNode }) {
8867
+ return createElement(UiOverrideProvider, { value }, children);
8868
+ }
8869
+ `;
8870
+ await Bun.write(absPath, source2);
8871
+ return absPath;
8872
+ }
8825
8873
  function getRootBoundarySegments(key) {
8826
8874
  const match = LAYOUT_KEY_RE.exec(key);
8827
8875
  if (!match)
@@ -9014,9 +9062,17 @@ async function resolveSsrPageEntries(opts) {
9014
9062
  const segments = getRootBoundarySegments(key);
9015
9063
  return segments !== null && isRootBoundarySegments(segments, basePaths);
9016
9064
  }));
9017
- const base = opts.pageKeys.filter((key) => !rootLayoutKeys.has(key)).map((key) => ({
9018
- key,
9019
- moduleAbsPath: path13.resolve(absPageDir, key)
9065
+ const base = await Promise.all(opts.pageKeys.filter((key) => !rootLayoutKeys.has(key)).map(async (key) => {
9066
+ const userAbsPath = path13.resolve(absPageDir, key);
9067
+ if (OVERRIDES_KEY_RE.test(key)) {
9068
+ const moduleAbsPath = await writeGeneratedOverridesLayoutFile({
9069
+ appCwdPath: opts.appCwdPath,
9070
+ key,
9071
+ userAbsPath
9072
+ });
9073
+ return { key, moduleAbsPath, seedAbsPaths: [userAbsPath] };
9074
+ }
9075
+ return { key, moduleAbsPath: userAbsPath };
9020
9076
  }));
9021
9077
  const generated = await Promise.all(rootBoundaries.map(async (boundary) => ({
9022
9078
  key: implicitRootLayoutKey(boundary.segments),
@@ -9048,7 +9104,7 @@ function computeRouteSeedIndex(pageEntries) {
9048
9104
  for (const { key, moduleAbsPath, seedAbsPaths } of pageEntries) {
9049
9105
  const parsed = parseRouteModuleKey2(key);
9050
9106
  const files = [path14.resolve(moduleAbsPath), ...(seedAbsPaths ?? []).map((seed) => path14.resolve(seed))];
9051
- if (parsed.kind === "layout") {
9107
+ if (parsed.kind === "layout" || parsed.kind === "overrides") {
9052
9108
  const prefix = parsed.routeSegments.join("/");
9053
9109
  const prev = layoutsByPrefix.get(prefix) ?? [];
9054
9110
  layoutsByPrefix.set(prefix, [...prev, ...files]);
@@ -15698,6 +15754,42 @@ var CONVENTION_SUFFIXES = [
15698
15754
  ".signal.ts",
15699
15755
  ".store.ts"
15700
15756
  ];
15757
+ var PAGE_RESERVED_EXPORTS = new Set([
15758
+ "pageConfig",
15759
+ "head",
15760
+ "metadata",
15761
+ "generateHead",
15762
+ "generateMetadata",
15763
+ "fonts",
15764
+ "manifest",
15765
+ "theme",
15766
+ "reconnect",
15767
+ "wsConnect",
15768
+ "layoutStyle",
15769
+ "gaTrackingId"
15770
+ ]);
15771
+ var RULE_FIXES = {
15772
+ "akan.global.duplicate-exported-function-name": "Rename one of the exports, or if they are the same thing, extract it into one shared module and import it in both places.",
15773
+ "akan.global.duplicate-exported-function-body": "Extract the shared implementation into a single exported helper and import it, instead of copying the body.",
15774
+ "akan.file.recommended-max-lines": "Split the file by responsibility \u2014 move Zones, Utils, or subcomponents into sibling files.",
15775
+ "akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
15776
+ "akan.file.placeholder-export": "Remove the placeholder export; generated indexes should only re-export real modules.",
15777
+ "akan.file.dictionary-stale-text": "Replace the scaffold text with real localized copy for this dictionary entry.",
15778
+ "akan.file.global-declaration": "Move the global declaration into an approved low-level integration file (e.g. webkit) and keep it isolated.",
15779
+ "akan.file.window-augmentation": "Move the Window augmentation into an approved browser integration file and keep it isolated.",
15780
+ "akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
15781
+ "akan.file.class-export-global-declaration": "Move the helper to a sibling file and import it into the class module.",
15782
+ "akan.file.component-internal-declaration": "Move the type or helper to a type/util file in ui/, webkit/, or common/ by purpose. If it is the component's props, declare it as `interface <Component>Props`.",
15783
+ "akan.file.component-export": "Move the value or type to a util/constant/type file in ui/, webkit/, or common/ and import it. Adding `export` is not a valid fix \u2014 only PascalCase components and their `<Component>Props` interface belong here.",
15784
+ "akan.layout.app-root-file": "Move the file into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit).",
15785
+ "akan.layout.lib-root-file": "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
15786
+ "akan.layout.module-ui-file": "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component."
15787
+ };
15788
+ function getRuleFix(rule) {
15789
+ if (rule.startsWith("akan.convention"))
15790
+ return "Keep only the model's allowed declarations in this file; move other logic to the matching domain file (service, document, store, etc.).";
15791
+ return RULE_FIXES[rule];
15792
+ }
15701
15793
 
15702
15794
  class AkanQualityScanner {
15703
15795
  async scan(workspaceRoot) {
@@ -15706,13 +15798,14 @@ class AkanQualityScanner {
15706
15798
  const warnings = [
15707
15799
  ...this.#scanGlobalQuality(sourceFiles),
15708
15800
  ...sourceFiles.flatMap((sourceFile2) => this.#scanSingleFileQuality(sourceFile2)),
15801
+ ...sourceFiles.flatMap((sourceFile2) => this.#scanComponentQuality(sourceFile2)),
15709
15802
  ...sourceFiles.flatMap((sourceFile2) => this.#scanConventionQuality(sourceFile2)),
15710
15803
  ...sourceFiles.flatMap((sourceFile2) => this.#scanLayoutQuality(sourceFile2))
15711
15804
  ];
15712
15805
  return {
15713
15806
  workspaceRoot,
15714
15807
  scannedFiles: sourceFiles.length,
15715
- warnings: warnings.sort(compareWarnings),
15808
+ warnings: warnings.map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) })).sort(compareWarnings),
15716
15809
  suggestedRules: SUGGESTED_RULES
15717
15810
  };
15718
15811
  }
@@ -15834,6 +15927,49 @@ class AkanQualityScanner {
15834
15927
  }
15835
15928
  return warnings;
15836
15929
  }
15930
+ #scanComponentQuality(sourceFile2) {
15931
+ if (!isComponentDeclarationFile(sourceFile2.file))
15932
+ return [];
15933
+ const isPage = isPageRouteFile(sourceFile2.file);
15934
+ const declarations = getComponentFileDeclarations(sourceFile2.sourceFile);
15935
+ const compoundComponentNames = getCompoundComponentNames(sourceFile2.sourceFile);
15936
+ const componentNames = declarations.filter((declaration) => declaration.exported && isComponentValueKind(declaration.kind)).filter((declaration) => isPascalCaseName(declaration.name)).map((declaration) => declaration.name);
15937
+ const allowedComponentNames = new Set([...componentNames, ...compoundComponentNames]);
15938
+ const allowedPropsInterfaces = new Set([...allowedComponentNames].map((name) => `${name}Props`));
15939
+ const warnings = [];
15940
+ for (const declaration of declarations) {
15941
+ if (declaration.isDefaultExport)
15942
+ continue;
15943
+ if (declaration.kind === "interface" && allowedPropsInterfaces.has(declaration.name))
15944
+ continue;
15945
+ if (declaration.exported) {
15946
+ if (isAllowedComponentExport(declaration, isPage))
15947
+ continue;
15948
+ warnings.push({
15949
+ rule: "akan.file.component-export",
15950
+ scope: "file",
15951
+ severity: "warning",
15952
+ file: sourceFile2.file,
15953
+ line: declaration.line,
15954
+ message: `Component file exports ${declaration.kind} "${declaration.name}", which is not a PascalCase component${isPage ? " or reserved route export" : ""}.`
15955
+ });
15956
+ continue;
15957
+ }
15958
+ if (isComponentValueKind(declaration.kind) && compoundComponentNames.has(declaration.name))
15959
+ continue;
15960
+ if (isRestrictedInternalKind(declaration.kind)) {
15961
+ warnings.push({
15962
+ rule: "akan.file.component-internal-declaration",
15963
+ scope: "file",
15964
+ severity: "warning",
15965
+ file: sourceFile2.file,
15966
+ line: declaration.line,
15967
+ message: `Component file declares non-exported ${declaration.kind} "${declaration.name}". Only "interface <Component>Props" may stay internal.`
15968
+ });
15969
+ }
15970
+ }
15971
+ return warnings;
15972
+ }
15837
15973
  #scanConventionQuality(sourceFile2) {
15838
15974
  const suffix = CONVENTION_SUFFIXES.find((candidate) => sourceFile2.file.endsWith(candidate));
15839
15975
  if (!suffix)
@@ -15909,6 +16045,8 @@ function formatQualityWarnings(warnings) {
15909
16045
  if (warning.locations?.length) {
15910
16046
  lines.push(...warning.locations.map(({ file, line }) => ` note: related location ${formatQualityLocation(file, line)}`));
15911
16047
  }
16048
+ if (warning.fix)
16049
+ lines.push(` fix: ${warning.fix}`);
15912
16050
  return lines;
15913
16051
  });
15914
16052
  }
@@ -15917,7 +16055,7 @@ function formatQualityLocation(file, line) {
15917
16055
  }
15918
16056
  function getExportedFunctionLikes(sourceFile2) {
15919
16057
  const declarations = [];
15920
- const pageExempt = isPageRouteFile(sourceFile2.file);
16058
+ const nameExempt = isPageRouteFile(sourceFile2.file) || isUiComponentFile(sourceFile2.file);
15921
16059
  for (const statement of sourceFile2.sourceFile.statements) {
15922
16060
  if (ts11.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
15923
16061
  declarations.push({
@@ -15926,7 +16064,7 @@ function getExportedFunctionLikes(sourceFile2) {
15926
16064
  file: sourceFile2.file,
15927
16065
  line: getLine(sourceFile2.sourceFile, statement),
15928
16066
  bodyFingerprint: getBodyFingerprint(sourceFile2.sourceFile, statement.body),
15929
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
16067
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
15930
16068
  });
15931
16069
  }
15932
16070
  if (ts11.isClassDeclaration(statement) && statement.name && isExported(statement)) {
@@ -15936,7 +16074,7 @@ function getExportedFunctionLikes(sourceFile2) {
15936
16074
  file: sourceFile2.file,
15937
16075
  line: getLine(sourceFile2.sourceFile, statement),
15938
16076
  bodyFingerprint: getBodyFingerprint(sourceFile2.sourceFile, statement),
15939
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile2.file, isEnumClassStatement(sourceFile2.sourceFile, statement))
16077
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile2.file, isEnumClassStatement(sourceFile2.sourceFile, statement))
15940
16078
  });
15941
16079
  }
15942
16080
  if (ts11.isVariableStatement(statement) && isExported(statement)) {
@@ -15949,7 +16087,7 @@ function getExportedFunctionLikes(sourceFile2) {
15949
16087
  file: sourceFile2.file,
15950
16088
  line: getLine(sourceFile2.sourceFile, declaration),
15951
16089
  bodyFingerprint: getBodyFingerprint(sourceFile2.sourceFile, declaration.initializer),
15952
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
16090
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
15953
16091
  });
15954
16092
  }
15955
16093
  }
@@ -15960,6 +16098,10 @@ function isPageRouteFile(file) {
15960
16098
  const segments = file.split("/");
15961
16099
  return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "page";
15962
16100
  }
16101
+ function isUiComponentFile(file) {
16102
+ const segments = file.split("/");
16103
+ return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "ui";
16104
+ }
15963
16105
  function isConventionDuplicateNameExempt(file, isEnumClass) {
15964
16106
  if (!isInLibModule(file))
15965
16107
  return false;
@@ -15985,6 +16127,86 @@ function isEnumClassStatement(sourceFile2, statement) {
15985
16127
  function getExportedClassNames(sourceFile2) {
15986
16128
  return sourceFile2.statements.filter((statement) => ts11.isClassDeclaration(statement) && !!statement.name).filter((statement) => isExported(statement)).map((statement) => statement.name.text);
15987
16129
  }
16130
+ function isComponentDeclarationFile(file) {
16131
+ if (!file.endsWith(".tsx"))
16132
+ return false;
16133
+ const segments = file.split("/");
16134
+ const [root, , area] = segments;
16135
+ if (root !== "apps" && root !== "libs")
16136
+ return false;
16137
+ if (area === "lib" || area === "ui")
16138
+ return true;
16139
+ return root === "apps" && area === "page";
16140
+ }
16141
+ function getComponentFileDeclarations(sourceFile2) {
16142
+ const reExportedNames = new Set;
16143
+ for (const statement of sourceFile2.statements) {
16144
+ if (ts11.isExportDeclaration(statement) && statement.exportClause && ts11.isNamedExports(statement.exportClause)) {
16145
+ for (const element of statement.exportClause.elements)
16146
+ reExportedNames.add((element.propertyName ?? element.name).text);
16147
+ }
16148
+ }
16149
+ const declarations = [];
16150
+ for (const statement of sourceFile2.statements) {
16151
+ const isDefaultExport = isDefaultExportStatement(statement);
16152
+ const inlineExported = isExported(statement);
16153
+ const add = (name, kind, line) => declarations.push({ name, kind, line, exported: inlineExported || reExportedNames.has(name), isDefaultExport });
16154
+ if (ts11.isInterfaceDeclaration(statement))
16155
+ add(statement.name.text, "interface", getLine(sourceFile2, statement));
16156
+ else if (ts11.isTypeAliasDeclaration(statement))
16157
+ add(statement.name.text, "type", getLine(sourceFile2, statement));
16158
+ else if (ts11.isEnumDeclaration(statement))
16159
+ add(statement.name.text, "enum", getLine(sourceFile2, statement));
16160
+ else if (ts11.isFunctionDeclaration(statement) && statement.name)
16161
+ add(statement.name.text, "function", getLine(sourceFile2, statement));
16162
+ else if (ts11.isClassDeclaration(statement) && statement.name)
16163
+ add(statement.name.text, "class", getLine(sourceFile2, statement));
16164
+ else if (ts11.isVariableStatement(statement)) {
16165
+ for (const declaration of statement.declarationList.declarations) {
16166
+ if (!ts11.isIdentifier(declaration.name))
16167
+ continue;
16168
+ const kind = isFunctionLikeInitializer(declaration.initializer) ? "function" : "variable";
16169
+ add(declaration.name.text, kind, getLine(sourceFile2, declaration));
16170
+ }
16171
+ }
16172
+ }
16173
+ return declarations;
16174
+ }
16175
+ function getCompoundComponentNames(sourceFile2) {
16176
+ const names = new Set;
16177
+ for (const statement of sourceFile2.statements) {
16178
+ if (!ts11.isExpressionStatement(statement))
16179
+ continue;
16180
+ const { expression } = statement;
16181
+ if (!ts11.isBinaryExpression(expression) || expression.operatorToken.kind !== ts11.SyntaxKind.EqualsToken)
16182
+ continue;
16183
+ if (!ts11.isPropertyAccessExpression(expression.left) || !isPascalCaseName(expression.left.name.text))
16184
+ continue;
16185
+ names.add(expression.left.name.text);
16186
+ if (ts11.isIdentifier(expression.right) && isPascalCaseName(expression.right.text))
16187
+ names.add(expression.right.text);
16188
+ }
16189
+ return names;
16190
+ }
16191
+ function isComponentValueKind(kind) {
16192
+ return kind === "variable" || kind === "function" || kind === "class";
16193
+ }
16194
+ function isRestrictedInternalKind(kind) {
16195
+ return kind === "interface" || kind === "type" || kind === "function";
16196
+ }
16197
+ function isAllowedComponentExport(declaration, isPage) {
16198
+ if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name))
16199
+ return true;
16200
+ return isPage && PAGE_RESERVED_EXPORTS.has(declaration.name);
16201
+ }
16202
+ function isPascalCaseName(name) {
16203
+ return /^[A-Z]/.test(name) && !/^[A-Z0-9_]+$/.test(name);
16204
+ }
16205
+ function isDefaultExportStatement(statement) {
16206
+ if (ts11.isExportAssignment(statement))
16207
+ return !statement.isExportEquals;
16208
+ return !!(ts11.getCombinedModifierFlags(statement) & ts11.ModifierFlags.Default);
16209
+ }
15988
16210
  function getPlaceholderExportWarnings(sourceFile2) {
15989
16211
  if (!sourceFile2.file.endsWith("/index.ts") && !sourceFile2.file.endsWith("/index.tsx"))
15990
16212
  return [];
package/index.js CHANGED
@@ -2689,6 +2689,31 @@ function validateRouteSourceExports(source, filePath, kind, options = {}) {
2689
2689
  throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
2690
2690
  }
2691
2691
  }
2692
+ function validateOverridesSourceExports(source, filePath) {
2693
+ const sourceFile = ts3.createSourceFile(filePath, source, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TSX);
2694
+ const fail = (message) => {
2695
+ throw new Error(`[route-convention] ${message}: ${filePath}`);
2696
+ };
2697
+ let defaultOverride = null;
2698
+ for (const statement of sourceFile.statements) {
2699
+ if (ts3.isExpressionStatement(statement) && ts3.isStringLiteral(statement.expression))
2700
+ continue;
2701
+ if (ts3.isImportDeclaration(statement))
2702
+ continue;
2703
+ if (ts3.isInterfaceDeclaration(statement) || ts3.isTypeAliasDeclaration(statement))
2704
+ continue;
2705
+ if (ts3.isExportAssignment(statement) && !statement.isExportEquals) {
2706
+ defaultOverride = statement;
2707
+ continue;
2708
+ }
2709
+ fail(`_overrides.tsx may only contain imports and a single "export default override({ ... })"`);
2710
+ }
2711
+ if (!defaultOverride)
2712
+ fail(`_overrides.tsx must "export default override({ ... })"`);
2713
+ const expression = defaultOverride.expression;
2714
+ if (!ts3.isCallExpression(expression) || !ts3.isIdentifier(expression.expression) || expression.expression.text !== "override")
2715
+ fail(`_overrides.tsx default export must be a call to "override", e.g. "export default override({ Modal: BrandModal })"`);
2716
+ }
2692
2717
 
2693
2718
  class Executor {
2694
2719
  static verbose = false;
@@ -3712,7 +3737,11 @@ class AppExecutor extends SysExecutor {
3712
3737
  throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
3713
3738
  }
3714
3739
  const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
3715
- validateRouteSourceExports(await Bun.file(absPath).text(), absPath, parsed.kind, { rootLayout: isRootLayout });
3740
+ const routeSource = await Bun.file(absPath).text();
3741
+ if (parsed.kind === "overrides")
3742
+ validateOverridesSourceExports(routeSource, absPath);
3743
+ else
3744
+ validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
3716
3745
  pageKeys.push(key);
3717
3746
  }
3718
3747
  pageKeys.sort();
@@ -8820,6 +8849,25 @@ async function appHasStModule(appCwdPath) {
8820
8849
  }
8821
8850
  var IMPLICIT_LAYOUT_DIR = path13.join(".akan", "generated", "root-layouts");
8822
8851
  var IMPLICIT_DICT_DIR = path13.join(".akan", "generated", "dict");
8852
+ var IMPLICIT_OVERRIDES_DIR = path13.join(".akan", "generated", "overrides");
8853
+ var OVERRIDES_KEY_RE = /^\.\/(.+\/)?_overrides\.(tsx|ts|jsx|js)$/;
8854
+ async function writeGeneratedOverridesLayoutFile(opts) {
8855
+ const filename = `${opts.key.replace(/^\.\//, "").replace(/[^a-zA-Z0-9]+/g, "_")}.tsx`;
8856
+ const absPath = path13.join(path13.resolve(opts.appCwdPath), IMPLICIT_OVERRIDES_DIR, filename);
8857
+ const userRel = path13.relative(path13.dirname(absPath), opts.userAbsPath).split(path13.sep).join("/");
8858
+ const userSpecifier = userRel.startsWith(".") ? userRel : `./${userRel}`;
8859
+ const source2 = `"use client";
8860
+ import { UiOverrideProvider } from "akanjs/ui";
8861
+ import { createElement, type ReactNode } from "react";
8862
+ import value from ${JSON.stringify(userSpecifier)};
8863
+
8864
+ export default function AkanUiOverridesLayout({ children }: { children?: ReactNode }) {
8865
+ return createElement(UiOverrideProvider, { value }, children);
8866
+ }
8867
+ `;
8868
+ await Bun.write(absPath, source2);
8869
+ return absPath;
8870
+ }
8823
8871
  function getRootBoundarySegments(key) {
8824
8872
  const match = LAYOUT_KEY_RE.exec(key);
8825
8873
  if (!match)
@@ -9012,9 +9060,17 @@ async function resolveSsrPageEntries(opts) {
9012
9060
  const segments = getRootBoundarySegments(key);
9013
9061
  return segments !== null && isRootBoundarySegments(segments, basePaths);
9014
9062
  }));
9015
- const base = opts.pageKeys.filter((key) => !rootLayoutKeys.has(key)).map((key) => ({
9016
- key,
9017
- moduleAbsPath: path13.resolve(absPageDir, key)
9063
+ const base = await Promise.all(opts.pageKeys.filter((key) => !rootLayoutKeys.has(key)).map(async (key) => {
9064
+ const userAbsPath = path13.resolve(absPageDir, key);
9065
+ if (OVERRIDES_KEY_RE.test(key)) {
9066
+ const moduleAbsPath = await writeGeneratedOverridesLayoutFile({
9067
+ appCwdPath: opts.appCwdPath,
9068
+ key,
9069
+ userAbsPath
9070
+ });
9071
+ return { key, moduleAbsPath, seedAbsPaths: [userAbsPath] };
9072
+ }
9073
+ return { key, moduleAbsPath: userAbsPath };
9018
9074
  }));
9019
9075
  const generated = await Promise.all(rootBoundaries.map(async (boundary) => ({
9020
9076
  key: implicitRootLayoutKey(boundary.segments),
@@ -9046,7 +9102,7 @@ function computeRouteSeedIndex(pageEntries) {
9046
9102
  for (const { key, moduleAbsPath, seedAbsPaths } of pageEntries) {
9047
9103
  const parsed = parseRouteModuleKey2(key);
9048
9104
  const files = [path14.resolve(moduleAbsPath), ...(seedAbsPaths ?? []).map((seed) => path14.resolve(seed))];
9049
- if (parsed.kind === "layout") {
9105
+ if (parsed.kind === "layout" || parsed.kind === "overrides") {
9050
9106
  const prefix = parsed.routeSegments.join("/");
9051
9107
  const prev = layoutsByPrefix.get(prefix) ?? [];
9052
9108
  layoutsByPrefix.set(prefix, [...prev, ...files]);
@@ -15696,6 +15752,42 @@ var CONVENTION_SUFFIXES = [
15696
15752
  ".signal.ts",
15697
15753
  ".store.ts"
15698
15754
  ];
15755
+ var PAGE_RESERVED_EXPORTS = new Set([
15756
+ "pageConfig",
15757
+ "head",
15758
+ "metadata",
15759
+ "generateHead",
15760
+ "generateMetadata",
15761
+ "fonts",
15762
+ "manifest",
15763
+ "theme",
15764
+ "reconnect",
15765
+ "wsConnect",
15766
+ "layoutStyle",
15767
+ "gaTrackingId"
15768
+ ]);
15769
+ var RULE_FIXES = {
15770
+ "akan.global.duplicate-exported-function-name": "Rename one of the exports, or if they are the same thing, extract it into one shared module and import it in both places.",
15771
+ "akan.global.duplicate-exported-function-body": "Extract the shared implementation into a single exported helper and import it, instead of copying the body.",
15772
+ "akan.file.recommended-max-lines": "Split the file by responsibility \u2014 move Zones, Utils, or subcomponents into sibling files.",
15773
+ "akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
15774
+ "akan.file.placeholder-export": "Remove the placeholder export; generated indexes should only re-export real modules.",
15775
+ "akan.file.dictionary-stale-text": "Replace the scaffold text with real localized copy for this dictionary entry.",
15776
+ "akan.file.global-declaration": "Move the global declaration into an approved low-level integration file (e.g. webkit) and keep it isolated.",
15777
+ "akan.file.window-augmentation": "Move the Window augmentation into an approved browser integration file and keep it isolated.",
15778
+ "akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
15779
+ "akan.file.class-export-global-declaration": "Move the helper to a sibling file and import it into the class module.",
15780
+ "akan.file.component-internal-declaration": "Move the type or helper to a type/util file in ui/, webkit/, or common/ by purpose. If it is the component's props, declare it as `interface <Component>Props`.",
15781
+ "akan.file.component-export": "Move the value or type to a util/constant/type file in ui/, webkit/, or common/ and import it. Adding `export` is not a valid fix \u2014 only PascalCase components and their `<Component>Props` interface belong here.",
15782
+ "akan.layout.app-root-file": "Move the file into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit).",
15783
+ "akan.layout.lib-root-file": "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
15784
+ "akan.layout.module-ui-file": "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component."
15785
+ };
15786
+ function getRuleFix(rule) {
15787
+ if (rule.startsWith("akan.convention"))
15788
+ return "Keep only the model's allowed declarations in this file; move other logic to the matching domain file (service, document, store, etc.).";
15789
+ return RULE_FIXES[rule];
15790
+ }
15699
15791
 
15700
15792
  class AkanQualityScanner {
15701
15793
  async scan(workspaceRoot) {
@@ -15704,13 +15796,14 @@ class AkanQualityScanner {
15704
15796
  const warnings = [
15705
15797
  ...this.#scanGlobalQuality(sourceFiles),
15706
15798
  ...sourceFiles.flatMap((sourceFile2) => this.#scanSingleFileQuality(sourceFile2)),
15799
+ ...sourceFiles.flatMap((sourceFile2) => this.#scanComponentQuality(sourceFile2)),
15707
15800
  ...sourceFiles.flatMap((sourceFile2) => this.#scanConventionQuality(sourceFile2)),
15708
15801
  ...sourceFiles.flatMap((sourceFile2) => this.#scanLayoutQuality(sourceFile2))
15709
15802
  ];
15710
15803
  return {
15711
15804
  workspaceRoot,
15712
15805
  scannedFiles: sourceFiles.length,
15713
- warnings: warnings.sort(compareWarnings),
15806
+ warnings: warnings.map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) })).sort(compareWarnings),
15714
15807
  suggestedRules: SUGGESTED_RULES
15715
15808
  };
15716
15809
  }
@@ -15832,6 +15925,49 @@ class AkanQualityScanner {
15832
15925
  }
15833
15926
  return warnings;
15834
15927
  }
15928
+ #scanComponentQuality(sourceFile2) {
15929
+ if (!isComponentDeclarationFile(sourceFile2.file))
15930
+ return [];
15931
+ const isPage = isPageRouteFile(sourceFile2.file);
15932
+ const declarations = getComponentFileDeclarations(sourceFile2.sourceFile);
15933
+ const compoundComponentNames = getCompoundComponentNames(sourceFile2.sourceFile);
15934
+ const componentNames = declarations.filter((declaration) => declaration.exported && isComponentValueKind(declaration.kind)).filter((declaration) => isPascalCaseName(declaration.name)).map((declaration) => declaration.name);
15935
+ const allowedComponentNames = new Set([...componentNames, ...compoundComponentNames]);
15936
+ const allowedPropsInterfaces = new Set([...allowedComponentNames].map((name) => `${name}Props`));
15937
+ const warnings = [];
15938
+ for (const declaration of declarations) {
15939
+ if (declaration.isDefaultExport)
15940
+ continue;
15941
+ if (declaration.kind === "interface" && allowedPropsInterfaces.has(declaration.name))
15942
+ continue;
15943
+ if (declaration.exported) {
15944
+ if (isAllowedComponentExport(declaration, isPage))
15945
+ continue;
15946
+ warnings.push({
15947
+ rule: "akan.file.component-export",
15948
+ scope: "file",
15949
+ severity: "warning",
15950
+ file: sourceFile2.file,
15951
+ line: declaration.line,
15952
+ message: `Component file exports ${declaration.kind} "${declaration.name}", which is not a PascalCase component${isPage ? " or reserved route export" : ""}.`
15953
+ });
15954
+ continue;
15955
+ }
15956
+ if (isComponentValueKind(declaration.kind) && compoundComponentNames.has(declaration.name))
15957
+ continue;
15958
+ if (isRestrictedInternalKind(declaration.kind)) {
15959
+ warnings.push({
15960
+ rule: "akan.file.component-internal-declaration",
15961
+ scope: "file",
15962
+ severity: "warning",
15963
+ file: sourceFile2.file,
15964
+ line: declaration.line,
15965
+ message: `Component file declares non-exported ${declaration.kind} "${declaration.name}". Only "interface <Component>Props" may stay internal.`
15966
+ });
15967
+ }
15968
+ }
15969
+ return warnings;
15970
+ }
15835
15971
  #scanConventionQuality(sourceFile2) {
15836
15972
  const suffix = CONVENTION_SUFFIXES.find((candidate) => sourceFile2.file.endsWith(candidate));
15837
15973
  if (!suffix)
@@ -15907,6 +16043,8 @@ function formatQualityWarnings(warnings) {
15907
16043
  if (warning.locations?.length) {
15908
16044
  lines.push(...warning.locations.map(({ file, line }) => ` note: related location ${formatQualityLocation(file, line)}`));
15909
16045
  }
16046
+ if (warning.fix)
16047
+ lines.push(` fix: ${warning.fix}`);
15910
16048
  return lines;
15911
16049
  });
15912
16050
  }
@@ -15915,7 +16053,7 @@ function formatQualityLocation(file, line) {
15915
16053
  }
15916
16054
  function getExportedFunctionLikes(sourceFile2) {
15917
16055
  const declarations = [];
15918
- const pageExempt = isPageRouteFile(sourceFile2.file);
16056
+ const nameExempt = isPageRouteFile(sourceFile2.file) || isUiComponentFile(sourceFile2.file);
15919
16057
  for (const statement of sourceFile2.sourceFile.statements) {
15920
16058
  if (ts11.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
15921
16059
  declarations.push({
@@ -15924,7 +16062,7 @@ function getExportedFunctionLikes(sourceFile2) {
15924
16062
  file: sourceFile2.file,
15925
16063
  line: getLine(sourceFile2.sourceFile, statement),
15926
16064
  bodyFingerprint: getBodyFingerprint(sourceFile2.sourceFile, statement.body),
15927
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
16065
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
15928
16066
  });
15929
16067
  }
15930
16068
  if (ts11.isClassDeclaration(statement) && statement.name && isExported(statement)) {
@@ -15934,7 +16072,7 @@ function getExportedFunctionLikes(sourceFile2) {
15934
16072
  file: sourceFile2.file,
15935
16073
  line: getLine(sourceFile2.sourceFile, statement),
15936
16074
  bodyFingerprint: getBodyFingerprint(sourceFile2.sourceFile, statement),
15937
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile2.file, isEnumClassStatement(sourceFile2.sourceFile, statement))
16075
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile2.file, isEnumClassStatement(sourceFile2.sourceFile, statement))
15938
16076
  });
15939
16077
  }
15940
16078
  if (ts11.isVariableStatement(statement) && isExported(statement)) {
@@ -15947,7 +16085,7 @@ function getExportedFunctionLikes(sourceFile2) {
15947
16085
  file: sourceFile2.file,
15948
16086
  line: getLine(sourceFile2.sourceFile, declaration),
15949
16087
  bodyFingerprint: getBodyFingerprint(sourceFile2.sourceFile, declaration.initializer),
15950
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
16088
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile2.file, false)
15951
16089
  });
15952
16090
  }
15953
16091
  }
@@ -15958,6 +16096,10 @@ function isPageRouteFile(file) {
15958
16096
  const segments = file.split("/");
15959
16097
  return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "page";
15960
16098
  }
16099
+ function isUiComponentFile(file) {
16100
+ const segments = file.split("/");
16101
+ return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "ui";
16102
+ }
15961
16103
  function isConventionDuplicateNameExempt(file, isEnumClass) {
15962
16104
  if (!isInLibModule(file))
15963
16105
  return false;
@@ -15983,6 +16125,86 @@ function isEnumClassStatement(sourceFile2, statement) {
15983
16125
  function getExportedClassNames(sourceFile2) {
15984
16126
  return sourceFile2.statements.filter((statement) => ts11.isClassDeclaration(statement) && !!statement.name).filter((statement) => isExported(statement)).map((statement) => statement.name.text);
15985
16127
  }
16128
+ function isComponentDeclarationFile(file) {
16129
+ if (!file.endsWith(".tsx"))
16130
+ return false;
16131
+ const segments = file.split("/");
16132
+ const [root, , area] = segments;
16133
+ if (root !== "apps" && root !== "libs")
16134
+ return false;
16135
+ if (area === "lib" || area === "ui")
16136
+ return true;
16137
+ return root === "apps" && area === "page";
16138
+ }
16139
+ function getComponentFileDeclarations(sourceFile2) {
16140
+ const reExportedNames = new Set;
16141
+ for (const statement of sourceFile2.statements) {
16142
+ if (ts11.isExportDeclaration(statement) && statement.exportClause && ts11.isNamedExports(statement.exportClause)) {
16143
+ for (const element of statement.exportClause.elements)
16144
+ reExportedNames.add((element.propertyName ?? element.name).text);
16145
+ }
16146
+ }
16147
+ const declarations = [];
16148
+ for (const statement of sourceFile2.statements) {
16149
+ const isDefaultExport = isDefaultExportStatement(statement);
16150
+ const inlineExported = isExported(statement);
16151
+ const add = (name, kind, line) => declarations.push({ name, kind, line, exported: inlineExported || reExportedNames.has(name), isDefaultExport });
16152
+ if (ts11.isInterfaceDeclaration(statement))
16153
+ add(statement.name.text, "interface", getLine(sourceFile2, statement));
16154
+ else if (ts11.isTypeAliasDeclaration(statement))
16155
+ add(statement.name.text, "type", getLine(sourceFile2, statement));
16156
+ else if (ts11.isEnumDeclaration(statement))
16157
+ add(statement.name.text, "enum", getLine(sourceFile2, statement));
16158
+ else if (ts11.isFunctionDeclaration(statement) && statement.name)
16159
+ add(statement.name.text, "function", getLine(sourceFile2, statement));
16160
+ else if (ts11.isClassDeclaration(statement) && statement.name)
16161
+ add(statement.name.text, "class", getLine(sourceFile2, statement));
16162
+ else if (ts11.isVariableStatement(statement)) {
16163
+ for (const declaration of statement.declarationList.declarations) {
16164
+ if (!ts11.isIdentifier(declaration.name))
16165
+ continue;
16166
+ const kind = isFunctionLikeInitializer(declaration.initializer) ? "function" : "variable";
16167
+ add(declaration.name.text, kind, getLine(sourceFile2, declaration));
16168
+ }
16169
+ }
16170
+ }
16171
+ return declarations;
16172
+ }
16173
+ function getCompoundComponentNames(sourceFile2) {
16174
+ const names = new Set;
16175
+ for (const statement of sourceFile2.statements) {
16176
+ if (!ts11.isExpressionStatement(statement))
16177
+ continue;
16178
+ const { expression } = statement;
16179
+ if (!ts11.isBinaryExpression(expression) || expression.operatorToken.kind !== ts11.SyntaxKind.EqualsToken)
16180
+ continue;
16181
+ if (!ts11.isPropertyAccessExpression(expression.left) || !isPascalCaseName(expression.left.name.text))
16182
+ continue;
16183
+ names.add(expression.left.name.text);
16184
+ if (ts11.isIdentifier(expression.right) && isPascalCaseName(expression.right.text))
16185
+ names.add(expression.right.text);
16186
+ }
16187
+ return names;
16188
+ }
16189
+ function isComponentValueKind(kind) {
16190
+ return kind === "variable" || kind === "function" || kind === "class";
16191
+ }
16192
+ function isRestrictedInternalKind(kind) {
16193
+ return kind === "interface" || kind === "type" || kind === "function";
16194
+ }
16195
+ function isAllowedComponentExport(declaration, isPage) {
16196
+ if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name))
16197
+ return true;
16198
+ return isPage && PAGE_RESERVED_EXPORTS.has(declaration.name);
16199
+ }
16200
+ function isPascalCaseName(name) {
16201
+ return /^[A-Z]/.test(name) && !/^[A-Z0-9_]+$/.test(name);
16202
+ }
16203
+ function isDefaultExportStatement(statement) {
16204
+ if (ts11.isExportAssignment(statement))
16205
+ return !statement.isExportEquals;
16206
+ return !!(ts11.getCombinedModifierFlags(statement) & ts11.ModifierFlags.Default);
16207
+ }
15986
16208
  function getPlaceholderExportWarnings(sourceFile2) {
15987
16209
  if (!sourceFile2.file.endsWith("/index.ts") && !sourceFile2.file.endsWith("/index.tsx"))
15988
16210
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/cli",
3
- "version": "2.3.11-rc.8",
3
+ "version": "2.3.11",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -34,7 +34,7 @@
34
34
  "@langchain/openai": "^1.4.6",
35
35
  "@tailwindcss/node": "^4.3.0",
36
36
  "@trapezedev/project": "^7.1.4",
37
- "akanjs": "2.3.11-rc.8",
37
+ "akanjs": "2.3.11",
38
38
  "chalk": "^5.6.2",
39
39
  "commander": "^14.0.3",
40
40
  "daisyui": "5.5.23",
@@ -10,7 +10,7 @@ alwaysApply: true
10
10
  - To test a built artifact locally, run it from the generated app directory with the required Akan runtime environment variables.
11
11
 
12
12
  ```bash
13
- cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=akansoft AKAN_PUBLIC_SERVE_DOMAIN="akamir.com" AKAN_PUBLIC_APP_NAME=seon AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation AKAN_PUBLIC_BASE_PATHS=neul,apptest,seon bun main.js
13
+ cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=akansoft AKAN_PUBLIC_SERVE_DOMAIN="akanjs.com" AKAN_PUBLIC_APP_NAME=seon AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation AKAN_PUBLIC_BASE_PATHS=operator,office,homepage bun main.js
14
14
  ```
15
15
 
16
16
  - Adjust `<appName>`, `AKAN_PUBLIC_APP_NAME`, and `AKAN_PUBLIC_BASE_PATHS` to match the app being tested.
@@ -9,5 +9,5 @@ alwaysApply: false
9
9
  - Use Bun and ESM assumptions from the root `tsconfig.json`.
10
10
  - Prefer path aliases over deep relative imports when crossing package boundaries.
11
11
  - Use `akanjs/*` for framework facets, `@apps/*` for apps, `@libs/*` for shared libs, and `@contract/*` for contract code.
12
- - Respect existing client/server entrypoints such as `@libs/shared/client`, `@libs/shared/server`, `@apps/puffinplace/client`, and `@apps/puffinplace/server`.
12
+ - Respect existing client/server entrypoints such as `@libs/shared/client`, `@libs/shared/server`, `@apps/<% appName %>/client`, and `@apps/<% appName %>/server`.
13
13
  - Let Biome organize imports instead of manually reshuffling unrelated imports.
@@ -132,6 +132,20 @@
132
132
  }
133
133
  },
134
134
  "overrides": [
135
+ {
136
+ "includes": [
137
+ "apps/**/*.ts",
138
+ "apps/**/*.tsx",
139
+ "libs/**/*.ts",
140
+ "libs/**/*.tsx",
141
+ "!**/*.test.ts",
142
+ "!**/*.test.tsx",
143
+ "!**/*.spec.ts",
144
+ "!**/*.spec.tsx",
145
+ "!**/common/**"
146
+ ],
147
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
148
+ },
135
149
  {
136
150
  "includes": ["**/page/**/*.ts", "**/page/**/*.tsx", "**/*.Unit.tsx", "**/*.View.tsx"],
137
151
  "plugins": [