@ingram-tech/nk-dev 0.9.0 → 0.10.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/bin/nk.js +8 -2
- package/lib/artifacts.js +105 -0
- package/lib/oxlint-plugins/index.js +2 -0
- package/lib/oxlint-plugins/satori-css.js +318 -0
- package/lib/passthrough.js +48 -2
- package/lib/run.js +34 -0
- package/oxlintrc.json +1 -0
- package/package.json +9 -8
package/bin/nk.js
CHANGED
|
@@ -5,7 +5,7 @@ import { doctor } from "../lib/doctor.js";
|
|
|
5
5
|
import { format } from "../lib/format.js";
|
|
6
6
|
import { init } from "../lib/init.js";
|
|
7
7
|
import { knip } from "../lib/knip.js";
|
|
8
|
-
import { build, check, lint, test, typeCheck } from "../lib/passthrough.js";
|
|
8
|
+
import { build, check, clean, lint, test, typeCheck } from "../lib/passthrough.js";
|
|
9
9
|
|
|
10
10
|
const USAGE = `nk — the nextkit CLI
|
|
11
11
|
|
|
@@ -27,7 +27,10 @@ Commands:
|
|
|
27
27
|
mechanical refactors — see the codemod skill.
|
|
28
28
|
check The CI gate: lint + format verify + knip (when configured)
|
|
29
29
|
+ the agent-guide import gate.
|
|
30
|
-
type-check next typegen && tsc --noEmit.
|
|
30
|
+
type-check next typegen && tsc --noEmit. Recovers automatically when
|
|
31
|
+
generated types are damaged (e.g. a killed dev server).
|
|
32
|
+
clean Remove regenerable build artifacts: Next's generated
|
|
33
|
+
types and TypeScript incremental caches.
|
|
31
34
|
test [...] vitest run (extra args passed through).
|
|
32
35
|
build [...] next build (extra args passed through).
|
|
33
36
|
|
|
@@ -64,6 +67,9 @@ switch (cmd) {
|
|
|
64
67
|
case "type-check":
|
|
65
68
|
typeCheck();
|
|
66
69
|
break;
|
|
70
|
+
case "clean":
|
|
71
|
+
clean();
|
|
72
|
+
break;
|
|
67
73
|
case "test":
|
|
68
74
|
test(rest);
|
|
69
75
|
break;
|
package/lib/artifacts.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Directories a tool regenerates from source, and which `tsconfig.json`
|
|
6
|
+
* typically feeds back into `tsc` (Next's typed-routes output is in `include`).
|
|
7
|
+
*
|
|
8
|
+
* That round trip is what makes them worth tracking: a killed `next dev` can
|
|
9
|
+
* leave `.next/dev/types/validator.ts` truncated mid-write, and `next typegen`
|
|
10
|
+
* does **not** repair it, so `tsc` keeps reporting a syntax error inside
|
|
11
|
+
* generated code until the directory is removed. The error points at `.next/`,
|
|
12
|
+
* so the natural reflex is to hunt a type error in `src/` that doesn't exist.
|
|
13
|
+
*/
|
|
14
|
+
const GENERATED_DIRECTORIES = [
|
|
15
|
+
{ path: ".next/dev/types", owner: "next typegen", typeCheckInput: true },
|
|
16
|
+
{ path: ".next/types", owner: "next typegen", typeCheckInput: true },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Prefixes (posix-normalised) that `tsc` error locations may fall inside. */
|
|
20
|
+
export const TYPE_CHECK_INPUT_PREFIXES = GENERATED_DIRECTORIES.filter(
|
|
21
|
+
(entry) => entry.typeCheckInput,
|
|
22
|
+
).map((entry) => entry.path);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every generated artifact present in `cwd`. Incremental-build caches are
|
|
26
|
+
* discovered rather than hardcoded, since `tsBuildInfoFile` renames them and a
|
|
27
|
+
* repo can carry several (`tsconfig.tsbuildinfo`, `tsconfig.slice.tsbuildinfo`).
|
|
28
|
+
*
|
|
29
|
+
* Deleting any of these is safe by construction: the owning tool rebuilds it.
|
|
30
|
+
*/
|
|
31
|
+
export function listGeneratedArtifacts(cwd = process.cwd()) {
|
|
32
|
+
const present = GENERATED_DIRECTORIES.filter((entry) =>
|
|
33
|
+
existsSync(join(cwd, entry.path)),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
let buildInfo = [];
|
|
37
|
+
try {
|
|
38
|
+
buildInfo = readdirSync(cwd)
|
|
39
|
+
.filter((name) => name.endsWith(".tsbuildinfo"))
|
|
40
|
+
.map((name) => ({ path: name, owner: "tsc", typeCheckInput: false }));
|
|
41
|
+
} catch {
|
|
42
|
+
// An unreadable cwd is the caller's problem, not this helper's.
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [...present, ...buildInfo];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remove generated artifacts and return the paths actually deleted. Missing
|
|
50
|
+
* paths are skipped rather than reported, so this is idempotent.
|
|
51
|
+
*/
|
|
52
|
+
export function cleanGeneratedArtifacts(cwd = process.cwd()) {
|
|
53
|
+
const removed = [];
|
|
54
|
+
for (const entry of listGeneratedArtifacts(cwd)) {
|
|
55
|
+
rmSync(join(cwd, entry.path), { recursive: true, force: true });
|
|
56
|
+
removed.push(entry.path);
|
|
57
|
+
}
|
|
58
|
+
return removed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Colour codes only (ESC [ … m). ESC is built from its code point rather
|
|
62
|
+
// than written inline: matching it is the entire point of stripping colour, but
|
|
63
|
+
// a control character inside a regex literal trips `no-control-regex`.
|
|
64
|
+
const ANSI = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `tsc` error headers, in both layouts: plain (piped / `--pretty false`) and
|
|
68
|
+
* pretty (a TTY, or `--pretty`).
|
|
69
|
+
*/
|
|
70
|
+
const ERROR_LOCATIONS = [
|
|
71
|
+
/^(.+?)\((\d+),(\d+)\): (?:error|warning) TS\d+/,
|
|
72
|
+
/^(.+?):(\d+):(\d+) - (?:error|warning) TS\d+/,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Unique file paths `tsc` reported errors in, posix-normalised. */
|
|
76
|
+
export function errorFiles(output) {
|
|
77
|
+
const files = new Set();
|
|
78
|
+
for (const rawLine of String(output).split(/\r?\n/)) {
|
|
79
|
+
const line = rawLine.replace(ANSI, "");
|
|
80
|
+
for (const pattern of ERROR_LOCATIONS) {
|
|
81
|
+
const match = line.match(pattern);
|
|
82
|
+
if (!match?.[1]) continue;
|
|
83
|
+
files.add(match[1].trim().replaceAll("\\", "/").replace(/^\.\//, ""));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...files];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether every error `tsc` reported sits inside generated type output — the
|
|
92
|
+
* signature of a damaged artifact rather than a source defect.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately "every", not "any": a run that mixes generated and `src/` errors
|
|
95
|
+
* has real work in it, and cleaning would neither fix nor excuse those.
|
|
96
|
+
*/
|
|
97
|
+
export function onlyGeneratedTypeErrors(output) {
|
|
98
|
+
const files = errorFiles(output);
|
|
99
|
+
if (files.length === 0) return false;
|
|
100
|
+
return files.every((file) =>
|
|
101
|
+
TYPE_CHECK_INPUT_PREFIXES.some(
|
|
102
|
+
(prefix) => file === prefix || file.startsWith(`${prefix}/`),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -8,6 +8,7 @@ import lucideIconSuffix from "./lucide-icon-suffix.js";
|
|
|
8
8
|
import noCryptoRandomUuid from "./no-crypto-random-uuid.js";
|
|
9
9
|
import noRedirectOnlyPage from "./no-redirect-only-page.js";
|
|
10
10
|
import redundantUseStateType from "./redundant-usestate-type.js";
|
|
11
|
+
import satoriCss from "./satori-css.js";
|
|
11
12
|
import tNoPositionalArgs from "./t-no-positional-args.js";
|
|
12
13
|
import tRequiresValues from "./t-requires-values.js";
|
|
13
14
|
|
|
@@ -20,6 +21,7 @@ export default {
|
|
|
20
21
|
...noCryptoRandomUuid.rules,
|
|
21
22
|
...noRedirectOnlyPage.rules,
|
|
22
23
|
...redundantUseStateType.rules,
|
|
24
|
+
...satoriCss.rules,
|
|
23
25
|
...tNoPositionalArgs.rules,
|
|
24
26
|
...tRequiresValues.rules,
|
|
25
27
|
},
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: validate inline styles in satori-rendered JSX.
|
|
2
|
+
//
|
|
3
|
+
// `next/og`'s `ImageResponse` types `style` as the full `React.CSSProperties`,
|
|
4
|
+
// but satori implements a finite subset and **silently drops** everything else.
|
|
5
|
+
// The image still renders, just wrong — which is why a render test can't catch
|
|
6
|
+
// it: the PNG is valid, the shadow is simply missing. That gap is the whole
|
|
7
|
+
// reason this rule exists (nk-seo README, "Open Graph image").
|
|
8
|
+
//
|
|
9
|
+
// Two classes of finding:
|
|
10
|
+
//
|
|
11
|
+
// 1. Style properties satori does not implement (`transition`, `cursor`,
|
|
12
|
+
// `backdropFilter`, the grid family, `zIndex`, `calc()`, …) — silent drops.
|
|
13
|
+
// 2. The structural rules satori enforces at render time: a node with more
|
|
14
|
+
// than one child must set `display: flex` (or `none`), and text must not
|
|
15
|
+
// sit next to element siblings. These *do* throw at render; flagging them
|
|
16
|
+
// in-editor just moves the failure earlier.
|
|
17
|
+
//
|
|
18
|
+
// Scope: only files that are satori-bound — they import `next/og` (or
|
|
19
|
+
// `@vercel/og`), or they are an `opengraph-image` / `twitter-image` file
|
|
20
|
+
// convention. Sites using nk-seo's `ogImageResponse` write no satori JSX at all
|
|
21
|
+
// and never trip this.
|
|
22
|
+
//
|
|
23
|
+
// The supported list is satori's documented one (https://github.com/vercel/satori#css)
|
|
24
|
+
// plus the box-model properties yoga handles that the README's table omits. It
|
|
25
|
+
// is deliberately generous: an over-wide allowlist only lowers the catch rate,
|
|
26
|
+
// while a too-narrow one puts false positives into a config the whole fleet
|
|
27
|
+
// inherits.
|
|
28
|
+
|
|
29
|
+
/** https://github.com/vercel/satori#css, plus the yoga box-model properties. */
|
|
30
|
+
const SUPPORTED = new Set([
|
|
31
|
+
// Display & position
|
|
32
|
+
"display",
|
|
33
|
+
"position",
|
|
34
|
+
"top",
|
|
35
|
+
"right",
|
|
36
|
+
"bottom",
|
|
37
|
+
"left",
|
|
38
|
+
"overflow",
|
|
39
|
+
"opacity",
|
|
40
|
+
"boxSizing",
|
|
41
|
+
"boxShadow",
|
|
42
|
+
"filter",
|
|
43
|
+
"clipPath",
|
|
44
|
+
"lineClamp",
|
|
45
|
+
"color",
|
|
46
|
+
// Box model
|
|
47
|
+
"margin",
|
|
48
|
+
"marginTop",
|
|
49
|
+
"marginRight",
|
|
50
|
+
"marginBottom",
|
|
51
|
+
"marginLeft",
|
|
52
|
+
"padding",
|
|
53
|
+
"paddingTop",
|
|
54
|
+
"paddingRight",
|
|
55
|
+
"paddingBottom",
|
|
56
|
+
"paddingLeft",
|
|
57
|
+
"width",
|
|
58
|
+
"height",
|
|
59
|
+
"minWidth",
|
|
60
|
+
"minHeight",
|
|
61
|
+
"maxWidth",
|
|
62
|
+
"maxHeight",
|
|
63
|
+
// Border
|
|
64
|
+
"border",
|
|
65
|
+
"borderTop",
|
|
66
|
+
"borderRight",
|
|
67
|
+
"borderBottom",
|
|
68
|
+
"borderLeft",
|
|
69
|
+
"borderWidth",
|
|
70
|
+
"borderTopWidth",
|
|
71
|
+
"borderRightWidth",
|
|
72
|
+
"borderBottomWidth",
|
|
73
|
+
"borderLeftWidth",
|
|
74
|
+
"borderStyle",
|
|
75
|
+
"borderTopStyle",
|
|
76
|
+
"borderRightStyle",
|
|
77
|
+
"borderBottomStyle",
|
|
78
|
+
"borderLeftStyle",
|
|
79
|
+
"borderColor",
|
|
80
|
+
"borderTopColor",
|
|
81
|
+
"borderRightColor",
|
|
82
|
+
"borderBottomColor",
|
|
83
|
+
"borderLeftColor",
|
|
84
|
+
"borderRadius",
|
|
85
|
+
"borderTopLeftRadius",
|
|
86
|
+
"borderTopRightRadius",
|
|
87
|
+
"borderBottomLeftRadius",
|
|
88
|
+
"borderBottomRightRadius",
|
|
89
|
+
// Flex
|
|
90
|
+
"flex",
|
|
91
|
+
"flexDirection",
|
|
92
|
+
"flexWrap",
|
|
93
|
+
"flexFlow",
|
|
94
|
+
"flexGrow",
|
|
95
|
+
"flexShrink",
|
|
96
|
+
"flexBasis",
|
|
97
|
+
"alignItems",
|
|
98
|
+
"alignContent",
|
|
99
|
+
"alignSelf",
|
|
100
|
+
"justifyContent",
|
|
101
|
+
"gap",
|
|
102
|
+
"rowGap",
|
|
103
|
+
"columnGap",
|
|
104
|
+
"order",
|
|
105
|
+
"aspectRatio",
|
|
106
|
+
// Font & text
|
|
107
|
+
"fontFamily",
|
|
108
|
+
"fontSize",
|
|
109
|
+
"fontWeight",
|
|
110
|
+
"fontStyle",
|
|
111
|
+
"tabSize",
|
|
112
|
+
"textAlign",
|
|
113
|
+
"textIndent",
|
|
114
|
+
"textTransform",
|
|
115
|
+
"textOverflow",
|
|
116
|
+
"textDecoration",
|
|
117
|
+
"textDecorationColor",
|
|
118
|
+
"textDecorationLine",
|
|
119
|
+
"textDecorationStyle",
|
|
120
|
+
"textShadow",
|
|
121
|
+
"textWrap",
|
|
122
|
+
"lineHeight",
|
|
123
|
+
"letterSpacing",
|
|
124
|
+
"whiteSpace",
|
|
125
|
+
"wordBreak",
|
|
126
|
+
// Background
|
|
127
|
+
"background",
|
|
128
|
+
"backgroundColor",
|
|
129
|
+
"backgroundImage",
|
|
130
|
+
"backgroundPosition",
|
|
131
|
+
"backgroundSize",
|
|
132
|
+
"backgroundClip",
|
|
133
|
+
"backgroundRepeat",
|
|
134
|
+
// Transform
|
|
135
|
+
"transform",
|
|
136
|
+
"transformOrigin",
|
|
137
|
+
// Image
|
|
138
|
+
"objectFit",
|
|
139
|
+
"objectPosition",
|
|
140
|
+
// Mask
|
|
141
|
+
"maskImage",
|
|
142
|
+
"maskPosition",
|
|
143
|
+
"maskSize",
|
|
144
|
+
"maskRepeat",
|
|
145
|
+
// Text stroke
|
|
146
|
+
"WebkitTextStroke",
|
|
147
|
+
"WebkitTextStrokeWidth",
|
|
148
|
+
"WebkitTextStrokeColor",
|
|
149
|
+
"WebkitBackgroundClip",
|
|
150
|
+
"WebkitTextFillColor",
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const OG_MODULES = new Set(["next/og", "@vercel/og"]);
|
|
154
|
+
const IMAGE_CONVENTION = /\/(opengraph-image|twitter-image)(\.[^/]+)?\.[jt]sx$/;
|
|
155
|
+
|
|
156
|
+
/** kebab-case is legal in a style object via string keys; normalize to camel. */
|
|
157
|
+
const toCamelCase = (name) =>
|
|
158
|
+
name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
159
|
+
|
|
160
|
+
const satoriCss = {
|
|
161
|
+
meta: {
|
|
162
|
+
type: "problem",
|
|
163
|
+
docs: {
|
|
164
|
+
description:
|
|
165
|
+
"Restrict inline styles in satori-rendered JSX to what satori implements",
|
|
166
|
+
},
|
|
167
|
+
messages: {
|
|
168
|
+
unsupportedProperty:
|
|
169
|
+
"satori does not implement `{{property}}` — it is silently dropped from the rendered image (the PNG still comes out, just wrong, so no render test catches it). See https://github.com/vercel/satori#css for the supported subset.",
|
|
170
|
+
calc: "satori does not support `calc()` in `{{property}}` — the declaration is dropped. Compute the number in JS instead.",
|
|
171
|
+
missingFlex:
|
|
172
|
+
'This element has {{count}} children but no `display`. satori defaults every node to `display: flex`, and a node with more than one child must set it explicitly — add `display: "flex"` (or `"none"`) to make the intent survive.',
|
|
173
|
+
textWithElementSiblings:
|
|
174
|
+
"satori cannot lay out a text node next to element siblings — it throws at render. Wrap the text in its own element.",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
create(context) {
|
|
178
|
+
const filename = context.physicalFilename || context.filename || "";
|
|
179
|
+
const isConventionFile = IMAGE_CONVENTION.test(filename.replace(/\\/g, "/"));
|
|
180
|
+
let importsOg = false;
|
|
181
|
+
/** Findings held until the whole file has been seen — the `next/og`
|
|
182
|
+
* import is what proves the JSX is satori-bound, and a re-export or a
|
|
183
|
+
* type-only file can carry JSX with no import at all. */
|
|
184
|
+
const findings = [];
|
|
185
|
+
const report = (descriptor) => findings.push(descriptor);
|
|
186
|
+
|
|
187
|
+
const checkStyleObject = (object) => {
|
|
188
|
+
for (const property of object.properties) {
|
|
189
|
+
if (property.type !== "Property") continue;
|
|
190
|
+
let name;
|
|
191
|
+
if (property.key.type === "Identifier" && !property.computed) {
|
|
192
|
+
name = property.key.name;
|
|
193
|
+
} else if (property.key.type === "Literal") {
|
|
194
|
+
name = String(property.key.value);
|
|
195
|
+
} else {
|
|
196
|
+
// A computed key is unknowable statically; a spread may carry
|
|
197
|
+
// anything. Silence beats guessing.
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// CSS custom properties are supported (with var() and fallbacks).
|
|
201
|
+
if (name.startsWith("--")) continue;
|
|
202
|
+
const camel = toCamelCase(name);
|
|
203
|
+
if (!SUPPORTED.has(camel)) {
|
|
204
|
+
report({
|
|
205
|
+
node: property.key,
|
|
206
|
+
messageId: "unsupportedProperty",
|
|
207
|
+
data: { property: name },
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
property.value.type === "Literal" &&
|
|
213
|
+
typeof property.value.value === "string" &&
|
|
214
|
+
property.value.value.includes("calc(")
|
|
215
|
+
) {
|
|
216
|
+
report({
|
|
217
|
+
node: property.value,
|
|
218
|
+
messageId: "calc",
|
|
219
|
+
data: { property: name },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Both structural checks only count children that are *certainly*
|
|
226
|
+
// rendered. A `{cond ? <a/> : null}` child may collapse to nothing, and
|
|
227
|
+
// counting it would flag templates that lay out fine — a false positive in
|
|
228
|
+
// a rule the whole fleet inherits costs more than the miss.
|
|
229
|
+
const isCertainElement = (child) =>
|
|
230
|
+
child.type === "JSXElement" ||
|
|
231
|
+
child.type === "JSXFragment" ||
|
|
232
|
+
(child.type === "JSXExpressionContainer" &&
|
|
233
|
+
(child.expression.type === "JSXElement" ||
|
|
234
|
+
child.expression.type === "JSXFragment"));
|
|
235
|
+
|
|
236
|
+
/** Raw text, or an interpolation that can only be a string/number. */
|
|
237
|
+
const isCertainText = (child) => {
|
|
238
|
+
if (child.type === "JSXText") return child.value.trim() !== "";
|
|
239
|
+
if (child.type !== "JSXExpressionContainer") return false;
|
|
240
|
+
const expression = child.expression;
|
|
241
|
+
return (
|
|
242
|
+
(expression.type === "Literal" &&
|
|
243
|
+
typeof expression.value !== "boolean") ||
|
|
244
|
+
expression.type === "TemplateLiteral"
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
ImportDeclaration(node) {
|
|
250
|
+
if (OG_MODULES.has(node.source.value)) importsOg = true;
|
|
251
|
+
},
|
|
252
|
+
JSXAttribute(node) {
|
|
253
|
+
if (node.name.type !== "JSXIdentifier" || node.name.name !== "style") {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const value = node.value;
|
|
257
|
+
if (value?.type !== "JSXExpressionContainer") return;
|
|
258
|
+
if (value.expression.type !== "ObjectExpression") return;
|
|
259
|
+
checkStyleObject(value.expression);
|
|
260
|
+
},
|
|
261
|
+
JSXElement(node) {
|
|
262
|
+
const texts = node.children.filter(isCertainText);
|
|
263
|
+
const elements = node.children.filter(isCertainElement);
|
|
264
|
+
if (texts.length > 0 && elements.length > 0) {
|
|
265
|
+
report({ node, messageId: "textWithElementSiblings" });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const children = texts.length + elements.length;
|
|
269
|
+
if (children < 2) return;
|
|
270
|
+
|
|
271
|
+
// A component's own JSX is that component's business; only the
|
|
272
|
+
// intrinsic elements satori lays out carry the flex rule.
|
|
273
|
+
if (node.openingElement.name.type !== "JSXIdentifier") return;
|
|
274
|
+
if (!/^[a-z]/.test(node.openingElement.name.name)) return;
|
|
275
|
+
|
|
276
|
+
let styleObject;
|
|
277
|
+
let hasUnknownStyle = false;
|
|
278
|
+
for (const attribute of node.openingElement.attributes) {
|
|
279
|
+
if (attribute.type === "JSXSpreadAttribute") {
|
|
280
|
+
hasUnknownStyle = true;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (attribute.name.name !== "style") continue;
|
|
284
|
+
if (attribute.value?.type !== "JSXExpressionContainer") {
|
|
285
|
+
hasUnknownStyle = true;
|
|
286
|
+
} else if (attribute.value.expression.type === "ObjectExpression") {
|
|
287
|
+
styleObject = attribute.value.expression;
|
|
288
|
+
} else {
|
|
289
|
+
hasUnknownStyle = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (hasUnknownStyle) return;
|
|
293
|
+
const declaresDisplay = styleObject?.properties.some(
|
|
294
|
+
(property) =>
|
|
295
|
+
property.type === "Property" &&
|
|
296
|
+
!property.computed &&
|
|
297
|
+
(property.key.name === "display" ||
|
|
298
|
+
property.key.value === "display"),
|
|
299
|
+
);
|
|
300
|
+
if (declaresDisplay) return;
|
|
301
|
+
report({
|
|
302
|
+
node: node.openingElement,
|
|
303
|
+
messageId: "missingFlex",
|
|
304
|
+
data: { count: String(children) },
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
"Program:exit"() {
|
|
308
|
+
if (!importsOg && !isConventionFile) return;
|
|
309
|
+
for (const descriptor of findings) context.report(descriptor);
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
export default {
|
|
316
|
+
meta: { name: "nextkit" },
|
|
317
|
+
rules: { "satori-css": satoriCss },
|
|
318
|
+
};
|
package/lib/passthrough.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { checkAgentGuideImport } from "./agent-guide.js";
|
|
2
|
+
import { cleanGeneratedArtifacts, onlyGeneratedTypeErrors } from "./artifacts.js";
|
|
2
3
|
import { toolDrift } from "./drift.js";
|
|
3
4
|
import { FORMATTER } from "./formatter.js";
|
|
4
5
|
import { hasKnipConfig, runKnip } from "./knip.js";
|
|
5
|
-
import { run } from "./run.js";
|
|
6
|
+
import { run, runCapture, writeThrough } from "./run.js";
|
|
6
7
|
|
|
7
8
|
/** `nk lint [...]` — oxlint, with extra args passed through (e.g. `--fix`). */
|
|
8
9
|
export function lint(extraArgs = []) {
|
|
@@ -48,13 +49,58 @@ function warnToolDrift() {
|
|
|
48
49
|
);
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* `nk type-check` — the house type-check: regenerate Next's types, then tsc.
|
|
54
|
+
*
|
|
55
|
+
* Recovers from damaged generated types. `tsconfig.json` feeds Next's
|
|
56
|
+
* typed-routes output back into `tsc`, and a killed dev server can leave it
|
|
57
|
+
* truncated mid-write; `next typegen` does not repair it, so the same syntax
|
|
58
|
+
* error inside `.next/` survives every re-run and reads as a source defect.
|
|
59
|
+
* When *every* reported error sits in generated output, the artifacts are
|
|
60
|
+
* cleaned and the check retried once — see {@link cleanGeneratedArtifacts}.
|
|
61
|
+
*
|
|
62
|
+
* The retry matters beyond the confusing message: a syntax error in generated
|
|
63
|
+
* output suppresses semantic diagnostics for the whole program, so real `src/`
|
|
64
|
+
* errors are hidden behind it. Recovering surfaces them and still exits
|
|
65
|
+
* non-zero — it never turns a failing check into a passing one.
|
|
66
|
+
*/
|
|
52
67
|
export function typeCheck() {
|
|
53
68
|
const typegen = run("next", ["typegen"]);
|
|
54
69
|
if (typegen !== 0) process.exit(typegen);
|
|
70
|
+
|
|
71
|
+
const first = runCapture("tsc", ["--noEmit"]);
|
|
72
|
+
if (first.status === 0 || !onlyGeneratedTypeErrors(first.output)) {
|
|
73
|
+
// Either a pass, or errors the caller needs to read and fix themselves.
|
|
74
|
+
writeThrough(first);
|
|
75
|
+
process.exit(first.status);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const removed = cleanGeneratedArtifacts();
|
|
79
|
+
console.error(
|
|
80
|
+
`nk type-check: every error was inside generated types — removed ${removed.join(", ")} and retrying.`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const regen = run("next", ["typegen"]);
|
|
84
|
+
if (regen !== 0) process.exit(regen);
|
|
85
|
+
// Retry with inherited stdio: this is the run whose output matters, and it
|
|
86
|
+
// keeps colour when a human is watching.
|
|
55
87
|
process.exit(run("tsc", ["--noEmit"]));
|
|
56
88
|
}
|
|
57
89
|
|
|
90
|
+
/**
|
|
91
|
+
* `nk clean` — remove build artifacts that tools regenerate from source
|
|
92
|
+
* (Next's generated types, TypeScript incremental caches). Safe by
|
|
93
|
+
* construction: whatever owns an artifact rebuilds it on the next run.
|
|
94
|
+
*/
|
|
95
|
+
export function clean() {
|
|
96
|
+
const removed = cleanGeneratedArtifacts();
|
|
97
|
+
if (removed.length === 0) {
|
|
98
|
+
console.log("nk clean: no generated artifacts found.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
console.log(`nk clean: removed ${removed.join(", ")}.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
/** `nk test [...]` — vitest run, with extra args passed through. */
|
|
59
105
|
export function test(extraArgs = []) {
|
|
60
106
|
process.exit(run("vitest", ["run", ...extraArgs]));
|
package/lib/run.js
CHANGED
|
@@ -21,6 +21,40 @@ export function run(tool, args = [], opts = {}) {
|
|
|
21
21
|
return res.status ?? (res.signal ? 1 : 0);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Like {@link run}, but captures the tool's output instead of inheriting stdio,
|
|
26
|
+
* so a caller can inspect it before deciding what to print. Returns the exit
|
|
27
|
+
* code plus the combined output; the caller is responsible for forwarding it.
|
|
28
|
+
*
|
|
29
|
+
* Colour is left to the tool: with stdio piped there is no TTY, so tools that
|
|
30
|
+
* auto-detect print plain text — which is also what makes their output
|
|
31
|
+
* parseable.
|
|
32
|
+
*/
|
|
33
|
+
export function runCapture(tool, args = [], opts = {}) {
|
|
34
|
+
const res = spawnSync("bun", ["x", tool, ...args], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
...opts,
|
|
37
|
+
});
|
|
38
|
+
if (res.error) {
|
|
39
|
+
if (res.error.code === "ENOENT") {
|
|
40
|
+
fail("could not run `bun` — is bun installed and on PATH?");
|
|
41
|
+
}
|
|
42
|
+
throw res.error;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
status: res.status ?? (res.signal ? 1 : 0),
|
|
46
|
+
stdout: res.stdout ?? "",
|
|
47
|
+
stderr: res.stderr ?? "",
|
|
48
|
+
output: `${res.stdout ?? ""}${res.stderr ?? ""}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Forward a {@link runCapture} result to this process's stdio, unchanged. */
|
|
53
|
+
export function writeThrough({ stdout, stderr }) {
|
|
54
|
+
if (stdout) process.stdout.write(stdout);
|
|
55
|
+
if (stderr) process.stderr.write(stderr);
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
/** Print an `nk:`-prefixed error and exit non-zero. */
|
|
25
59
|
export function fail(message) {
|
|
26
60
|
console.error(`nk: ${message}`);
|
package/oxlintrc.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"nextkit/t-requires-values": "error",
|
|
14
14
|
"nextkit/t-no-positional-args": "error",
|
|
15
15
|
"nextkit/no-crypto-random-uuid": "warn",
|
|
16
|
+
"nextkit/satori-css": "warn",
|
|
16
17
|
"no-unused-vars": "warn",
|
|
17
18
|
"typescript/no-non-null-assertion": "error",
|
|
18
19
|
"typescript/no-explicit-any": "error",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ingram-tech/nk-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The nextkit dev toolchain in one package: the `nk` CLI plus shared oxlint/oxfmt, TypeScript, and Vitest config, the format-on-commit hook, and the AI agent guide. `nk init` scaffolds a site to use it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,17 +45,18 @@
|
|
|
45
45
|
"test": "vitest run"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@ast-grep/cli": "^0.
|
|
49
|
-
"@testing-library/
|
|
48
|
+
"@ast-grep/cli": "^0.45.0",
|
|
49
|
+
"@testing-library/dom": "^10.4.1",
|
|
50
|
+
"@testing-library/jest-dom": "^7.0.0",
|
|
50
51
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
51
|
-
"jsdom": "^
|
|
52
|
-
"knip": "^6.
|
|
53
|
-
"oxfmt": "^0.
|
|
54
|
-
"oxlint": "^1.
|
|
52
|
+
"jsdom": "^30.0.1",
|
|
53
|
+
"knip": "^6.31.0",
|
|
54
|
+
"oxfmt": "^0.61.0",
|
|
55
|
+
"oxlint": "^1.76.0",
|
|
55
56
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
56
57
|
"vitest": "^4.1.10"
|
|
57
58
|
},
|
|
58
59
|
"engines": {
|
|
59
|
-
"node": ">=
|
|
60
|
+
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
|
60
61
|
}
|
|
61
62
|
}
|