@akanjs/devkit 2.4.2-rc.2 → 3.0.0-alpha.0
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/abstractCompactor.ts +112 -0
- package/abstractDoc.test.ts +78 -0
- package/abstractDoc.ts +70 -0
- package/agentsIndex.test.ts +84 -0
- package/agentsIndex.ts +146 -0
- package/akanContext.ts +122 -0
- package/executors.ts +26 -0
- package/frontendBuild/frontendBuild.test.ts +3 -1
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -0
- package/frontendBuild/styleContract.ts +29 -0
- package/frontendBuild/styleGuard.test.ts +165 -0
- package/frontendBuild/styleGuard.ts +322 -0
- package/frontendBuild/themeValidator.test.ts +70 -0
- package/frontendBuild/themeValidator.ts +150 -0
- package/index.ts +1 -0
- package/lint/no-arbitrary-color.grit +19 -0
- package/lint/no-daisyui-legacy-class.grit +20 -0
- package/lint/no-inline-color.grit +19 -0
- package/lint/no-interpolated-arbitrary-class.grit +24 -0
- package/lint/no-raw-palette-class.grit +25 -0
- package/package.json +2 -3
- package/qualityScanner.test.ts +46 -0
- package/qualityScanner.ts +40 -2
- package/recipeScanner.test.ts +183 -0
- package/recipeScanner.ts +233 -0
- package/scanInfo.ts +3 -0
- package/transforms/externalizeFrameworkPlugin.ts +2 -2
package/recipeScanner.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single Akan UI recipe discovered by scanning source. `variants` maps each variant key to its allowed option
|
|
6
|
+
* names (e.g. `{ variant: ["primary", "ghost"], size: ["sm", "md"] }`); a base-only recipe has `variants: {}`.
|
|
7
|
+
* `importFrom` is the module a consumer imports the recipe from (e.g. `@apps/minimal/ui`).
|
|
8
|
+
*/
|
|
9
|
+
export interface RecipeInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
importFrom: string;
|
|
12
|
+
variants: Record<string, string[]>;
|
|
13
|
+
defaultVariants?: Record<string, string>;
|
|
14
|
+
doc?: string;
|
|
15
|
+
/** The recipe's `base` class string when it is a plain string literal — the SSOT fingerprint. */
|
|
16
|
+
base?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RecipeSource {
|
|
20
|
+
path: string;
|
|
21
|
+
content: string;
|
|
22
|
+
importFrom: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collects every recipe source under a `ui` folder. Recipes live one-per-file in a `Recipe/` folder
|
|
27
|
+
* (`recipe/` for the framework), so this reads the whole folder; the flat `Recipe.ts` is still read for
|
|
28
|
+
* apps that have not moved yet. Every consumer of `scanRecipes` must go through here — three call sites
|
|
29
|
+
* (AGENTS.md recipe index, `recipeGate` lint, MCP module context) hardcoded the flat path before, and each
|
|
30
|
+
* one fails silently (empty list, no error) when the file is absent.
|
|
31
|
+
*/
|
|
32
|
+
export const collectRecipeSources = async (
|
|
33
|
+
uiDirPath: string,
|
|
34
|
+
importFrom: string,
|
|
35
|
+
basename = "Recipe",
|
|
36
|
+
): Promise<RecipeSource[]> => {
|
|
37
|
+
const read = async (filePath: string): Promise<RecipeSource | null> => {
|
|
38
|
+
const content = await Bun.file(filePath)
|
|
39
|
+
.text()
|
|
40
|
+
.catch(() => "");
|
|
41
|
+
return content ? { path: filePath, content, importFrom } : null;
|
|
42
|
+
};
|
|
43
|
+
const flat = await read(`${uiDirPath}/${basename}.ts`);
|
|
44
|
+
const dirEntries = await readdir(`${uiDirPath}/${basename}`).catch(() => [] as string[]);
|
|
45
|
+
const fromDir = await Promise.all(
|
|
46
|
+
dirEntries
|
|
47
|
+
.filter((entry) => entry.endsWith(".ts") && entry !== "index.ts" && !/\.(test|spec)\.ts$/.test(entry))
|
|
48
|
+
.sort()
|
|
49
|
+
.map((entry) => read(`${uiDirPath}/${basename}/${entry}`)),
|
|
50
|
+
);
|
|
51
|
+
return [flat, ...fromDir].filter((source): source is RecipeSource => !!source);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Statically finds every `export const <name> = recipe(tv({ ... }))` across the given sources and extracts its
|
|
56
|
+
* variant surface + leading JSDoc one-liner. Detection is by the `recipe(tv(...))` call shape (not by name suffix,
|
|
57
|
+
* since base-only recipes like `appScreen` omit the `Recipe` suffix). Being AST-based, it never matches recipe
|
|
58
|
+
* definitions that appear only inside string/template literals (e.g. code examples in docs pages).
|
|
59
|
+
*/
|
|
60
|
+
export const scanRecipes = (sources: RecipeSource[]): RecipeInfo[] => {
|
|
61
|
+
const recipes: RecipeInfo[] = [];
|
|
62
|
+
for (const source of sources) {
|
|
63
|
+
const sourceFile = ts.createSourceFile(
|
|
64
|
+
source.path,
|
|
65
|
+
source.content,
|
|
66
|
+
ts.ScriptTarget.Latest,
|
|
67
|
+
true,
|
|
68
|
+
ts.ScriptKind.TSX,
|
|
69
|
+
);
|
|
70
|
+
for (const statement of sourceFile.statements) {
|
|
71
|
+
if (!ts.isVariableStatement(statement) || !isExported(statement)) continue;
|
|
72
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
73
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
|
74
|
+
const parsed = parseRecipeCall(declaration.initializer);
|
|
75
|
+
if (!parsed) continue;
|
|
76
|
+
recipes.push({
|
|
77
|
+
name: declaration.name.text,
|
|
78
|
+
importFrom: source.importFrom,
|
|
79
|
+
variants: parsed.variants,
|
|
80
|
+
...(parsed.defaultVariants ? { defaultVariants: parsed.defaultVariants } : {}),
|
|
81
|
+
...(getLeadingDoc(source.content, statement) ? { doc: getLeadingDoc(source.content, statement) } : {}),
|
|
82
|
+
...(parsed.base ? { base: parsed.base } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return recipes;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isExported = (statement: ts.VariableStatement): boolean =>
|
|
91
|
+
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
92
|
+
|
|
93
|
+
/** Matches `recipe( tv( <ObjectLiteral> ) )` and returns the variant surface, or null for anything else. */
|
|
94
|
+
const parseRecipeCall = (
|
|
95
|
+
initializer: ts.Expression,
|
|
96
|
+
): { variants: Record<string, string[]>; defaultVariants?: Record<string, string>; base?: string } | null => {
|
|
97
|
+
if (!ts.isCallExpression(initializer)) return null;
|
|
98
|
+
if (!ts.isIdentifier(initializer.expression) || initializer.expression.text !== "recipe") return null;
|
|
99
|
+
const tvCall = initializer.arguments[0];
|
|
100
|
+
if (!tvCall || !ts.isCallExpression(tvCall)) return null;
|
|
101
|
+
if (!ts.isIdentifier(tvCall.expression) || tvCall.expression.text !== "tv") return null;
|
|
102
|
+
const config = tvCall.arguments[0];
|
|
103
|
+
if (!config || !ts.isObjectLiteralExpression(config)) return null;
|
|
104
|
+
return extractVariants(config);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const extractVariants = (config: ts.ObjectLiteralExpression) => {
|
|
108
|
+
const variants: Record<string, string[]> = {};
|
|
109
|
+
let defaultVariants: Record<string, string> | undefined;
|
|
110
|
+
let base: string | undefined;
|
|
111
|
+
for (const property of config.properties) {
|
|
112
|
+
if (!ts.isPropertyAssignment(property) || !isNamed(property.name)) continue;
|
|
113
|
+
const key = propName(property.name);
|
|
114
|
+
if (key === "base" && ts.isStringLiteral(property.initializer)) {
|
|
115
|
+
base = property.initializer.text;
|
|
116
|
+
} else if (key === "variants" && ts.isObjectLiteralExpression(property.initializer)) {
|
|
117
|
+
for (const variant of property.initializer.properties) {
|
|
118
|
+
if (!ts.isPropertyAssignment(variant) || !isNamed(variant.name)) continue;
|
|
119
|
+
if (!ts.isObjectLiteralExpression(variant.initializer)) continue;
|
|
120
|
+
variants[propName(variant.name)] = variant.initializer.properties
|
|
121
|
+
.filter((option): option is ts.PropertyAssignment => ts.isPropertyAssignment(option) && isNamed(option.name))
|
|
122
|
+
.map((option) => propName(option.name));
|
|
123
|
+
}
|
|
124
|
+
} else if (key === "defaultVariants" && ts.isObjectLiteralExpression(property.initializer)) {
|
|
125
|
+
defaultVariants = {};
|
|
126
|
+
for (const preset of property.initializer.properties) {
|
|
127
|
+
if (!ts.isPropertyAssignment(preset) || !isNamed(preset.name)) continue;
|
|
128
|
+
defaultVariants[propName(preset.name)] = ts.isStringLiteral(preset.initializer)
|
|
129
|
+
? preset.initializer.text
|
|
130
|
+
: preset.initializer.getText();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { variants, defaultVariants, base };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const isNamed = (name: ts.PropertyName): name is ts.Identifier | ts.StringLiteral =>
|
|
138
|
+
ts.isIdentifier(name) || ts.isStringLiteral(name);
|
|
139
|
+
const propName = (name: ts.Identifier | ts.StringLiteral): string => name.text;
|
|
140
|
+
|
|
141
|
+
export interface RecipeDuplicate {
|
|
142
|
+
recipe: string;
|
|
143
|
+
path: string;
|
|
144
|
+
line: number;
|
|
145
|
+
className: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fraction of a recipe's base tokens an inline className must reproduce to count as a duplicate.
|
|
150
|
+
*
|
|
151
|
+
* Requiring *every* token (the original rule) only caught a verbatim copy of the whole base, which is the one
|
|
152
|
+
* form of duplication that essentially never happens: someone re-authoring a look reproduces the gist, not all
|
|
153
|
+
* eight tokens. So the check passed on exactly the near-duplicates it existed to find, and silently — it is an
|
|
154
|
+
* advisory, so nothing went red. A ratio catches those; false positives are cheap here for the same reason.
|
|
155
|
+
*/
|
|
156
|
+
const DUPLICATE_TOKEN_RATIO = 0.7;
|
|
157
|
+
|
|
158
|
+
/** Minimum base tokens for a recipe to be worth fingerprinting at all. */
|
|
159
|
+
const MIN_FINGERPRINT_TOKENS = 3;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* SSOT advisory: finds JSX `className` string values that hand-rewrite a recipe's base fingerprint instead of
|
|
163
|
+
* consuming the recipe. Only recipes whose base has 3+ distinctive tokens are checked — shorter fingerprints
|
|
164
|
+
* (`grid gap-3` …) are generic utilities and would flood the report with false positives. A className counts as
|
|
165
|
+
* a duplicate once it reproduces {@link DUPLICATE_TOKEN_RATIO} of those tokens, so a near-copy that drops or
|
|
166
|
+
* swaps one still reports. AST-scoped to real `className` attributes, so class strings inside doc-example
|
|
167
|
+
* template literals never match.
|
|
168
|
+
*/
|
|
169
|
+
export const findInlineRecipeDuplicates = (
|
|
170
|
+
recipes: RecipeInfo[],
|
|
171
|
+
files: { path: string; content: string }[],
|
|
172
|
+
): RecipeDuplicate[] => {
|
|
173
|
+
const fingerprints = recipes
|
|
174
|
+
.map((recipe) => ({ recipe: recipe.name, tokens: (recipe.base ?? "").split(/\s+/).filter(Boolean) }))
|
|
175
|
+
.filter((fingerprint) => fingerprint.tokens.length >= MIN_FINGERPRINT_TOKENS)
|
|
176
|
+
// Ceil so the threshold never rounds below the minimum: a 3-token base still needs 3 of 3.
|
|
177
|
+
.map((fingerprint) => ({ ...fingerprint, needed: Math.ceil(fingerprint.tokens.length * DUPLICATE_TOKEN_RATIO) }));
|
|
178
|
+
if (fingerprints.length === 0) return [];
|
|
179
|
+
const duplicates: RecipeDuplicate[] = [];
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
const sourceFile = ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
182
|
+
const visit = (node: ts.Node) => {
|
|
183
|
+
if (ts.isJsxAttribute(node) && node.name.getText(sourceFile) === "className" && node.initializer) {
|
|
184
|
+
for (const value of stringValuesIn(node.initializer)) {
|
|
185
|
+
const classSet = new Set(value.split(/\s+/));
|
|
186
|
+
for (const fingerprint of fingerprints) {
|
|
187
|
+
const matched = fingerprint.tokens.filter((token) => classSet.has(token)).length;
|
|
188
|
+
if (matched >= fingerprint.needed) {
|
|
189
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
190
|
+
duplicates.push({ recipe: fingerprint.recipe, path: file.path, line: line + 1, className: value });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
node.forEachChild(visit);
|
|
196
|
+
};
|
|
197
|
+
visit(sourceFile);
|
|
198
|
+
}
|
|
199
|
+
return duplicates;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const stringValuesIn = (node: ts.Node): string[] => {
|
|
203
|
+
const values: string[] = [];
|
|
204
|
+
const visit = (child: ts.Node) => {
|
|
205
|
+
if (
|
|
206
|
+
ts.isStringLiteral(child) ||
|
|
207
|
+
ts.isNoSubstitutionTemplateLiteral(child) ||
|
|
208
|
+
ts.isTemplateHead(child) ||
|
|
209
|
+
ts.isTemplateMiddle(child) ||
|
|
210
|
+
ts.isTemplateTail(child)
|
|
211
|
+
)
|
|
212
|
+
values.push(child.text);
|
|
213
|
+
child.forEachChild(visit);
|
|
214
|
+
};
|
|
215
|
+
visit(node);
|
|
216
|
+
return values;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** The first non-empty line of the JSDoc/line comment immediately preceding the statement, markers stripped. */
|
|
220
|
+
const getLeadingDoc = (fullText: string, node: ts.Node): string | undefined => {
|
|
221
|
+
const ranges = ts.getLeadingCommentRanges(fullText, node.getFullStart());
|
|
222
|
+
if (!ranges?.length) return undefined;
|
|
223
|
+
const { pos, end } = ranges[ranges.length - 1];
|
|
224
|
+
const line = fullText
|
|
225
|
+
.slice(pos, end)
|
|
226
|
+
.replace(/^\/\*\*?/, "")
|
|
227
|
+
.replace(/\*\/\s*$/, "")
|
|
228
|
+
.replace(/^\/\/+/gm, "")
|
|
229
|
+
.split("\n")
|
|
230
|
+
.map((row) => row.replace(/^\s*\*\s?/, "").trim())
|
|
231
|
+
.find((row) => row.length > 0);
|
|
232
|
+
return line || undefined;
|
|
233
|
+
};
|
package/scanInfo.ts
CHANGED
|
@@ -44,6 +44,9 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
|
|
|
44
44
|
type ModuleKind = "database" | "service" | "scalar";
|
|
45
45
|
|
|
46
46
|
const appRootAllowedFiles = new Set([
|
|
47
|
+
// 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
|
|
48
|
+
"AGENTS.md",
|
|
49
|
+
"CLAUDE.md",
|
|
47
50
|
"akan.app.json",
|
|
48
51
|
"akan.config.ts",
|
|
49
52
|
"capacitor.config.ts",
|
|
@@ -11,7 +11,7 @@ import type { App } from "../commandDecorators";
|
|
|
11
11
|
* - `packages: "external"` externalizes every bare specifier, including
|
|
12
12
|
* `@apps/*` / `@libs/*` — workspace packages that must stay bundled
|
|
13
13
|
* so the `"use client"` plugin can rewrite their exports — and ordinary
|
|
14
|
-
* npm dependencies like `dayjs` / `
|
|
14
|
+
* npm dependencies like `dayjs` / `tailwind-merge`, which the production runtime
|
|
15
15
|
* package.json does not install for the SSR pages artifact.
|
|
16
16
|
* - Top-level `external: [...]` applies to macro-time module resolution
|
|
17
17
|
* too; any `with { type: "macro" }` import chain that transitively
|
|
@@ -169,7 +169,7 @@ export async function createExternalizeFrameworkPlugin(options: ExternalizeFrame
|
|
|
169
169
|
// that case directly.
|
|
170
170
|
return undefined;
|
|
171
171
|
}
|
|
172
|
-
// Everything else (ordinary npm dependencies like dayjs /
|
|
172
|
+
// Everything else (ordinary npm dependencies like dayjs / tailwind-merge /
|
|
173
173
|
// immer) is bundled into the pages artifact.
|
|
174
174
|
return undefined;
|
|
175
175
|
});
|