larrylint 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -33,12 +33,12 @@ After that, `eslint .` and your editor report larrylint's rules next to your own
33
33
 
34
34
  | Rule | Description |
35
35
  | --- | --- |
36
- | `larrylint/layers` | App, server and shared code stay apart, and runtime code doesn't import build-time code, or `node:` modules outside the server. Nothing imports orchestr handlers, and handlers get API clients from the orchestr context. Utils are the bottom layer, followed by composables, components and sections. |
37
- | `larrylint/heavy-imports` | Sections, blocks and plugins load the packages in `heavyPackages` with `import()`: frontend-core imports every section and block up front, and plugins run on every page, so a static import lands in the chunk every page loads. |
36
+ | `larrylint/layers` | App, server and shared code don't import each other, runtime code doesn't import build-time code, and `node:` modules stay on the server. As Laioutr's coding standards ask, handlers get API clients from the orchestr context, nothing imports handler files, and components don't import sections or blocks. |
37
+ | `larrylint/heavy-imports` | Sections, blocks and plugins load the packages in `heavyPackages` with `import()`: frontend-core imports every section and block up front, and plugins run on every page, so a static import loads the package on every page. |
38
38
  | `larrylint/config-keys` | Runtime config keys like `'@laioutr-app/shopware'` are the app itself or one of its dependencies, otherwise the config is missing at runtime. |
39
- | `larrylint/public-config` | The module doesn't copy its whole options object into `runtimeConfig.public`, which reaches the browser with every token among them. |
39
+ | `larrylint/public-config` | The module doesn't copy its whole options object into `runtimeConfig.public`, which would send every secret in it to the browser. |
40
40
 
41
- Type imports are fine across most layers, since they don't end up in the bundle.
41
+ Type imports are always fine, since they don't end up in the bundle.
42
42
 
43
43
  ### Orchestr
44
44
 
@@ -47,66 +47,61 @@ Type imports are fine across most layers, since they don't end up in the bundle.
47
47
  | `larrylint/orchestr-files` | Laioutr loads every file in `orchestr/` as a Nitro plugin, so only handler files belong there. |
48
48
  | `larrylint/orchestr-cookies` | Cookies and headers are only written in `extendRequest()` and action handlers, since query, link and resolver handlers and `use()` middleware can run after the headers are sent. Cookies go through frontend-core's `setManagedCookie()` and `deleteManagedCookie()`, which the Studio preview needs. |
49
49
  | `larrylint/handler-exports` | Handler files export their handler as default. Without one, the build fails. |
50
- | `larrylint/middleware-files` | `server/middleware/` only holds orchestr and Nitro middleware; helpers go to `server/utils/`. |
51
- | `larrylint/initware-throws` | `extendRequest()` doesn't throw, directly or through a function it calls: it runs before every query, so a throw takes down every page. |
50
+ | `larrylint/initware-throws` | `extendRequest()` doesn't throw, directly or through a function it calls: it runs before every query, so a throw breaks every page. |
52
51
  | `larrylint/token-namespaces` | Your own tokens stay out of the namespaces of Laioutr's canonical types, like `ecommerce/`, where a later canonical-types release or another app can take the same id. |
53
52
 
54
53
  ### Sections and blocks
55
54
 
56
55
  | Rule | Description |
57
56
  | --- | --- |
58
- | `larrylint/definition-folder` | `defineSection()` lives in `app/sections/` and `defineBlock()` in `app/blocks/` (or `section/` and `block/`), and every `.vue` there has one. |
57
+ | `larrylint/definition-folder` | `defineSection()` and `defineBlock()` live where `module.ts` registers sections and blocks, and every `.vue` registered there has one. Files that `module.ts` wires up by hand, like replacements for upstream sections, can live anywhere. |
59
58
  | `larrylint/component-name` | The `component` of a definition matches its file name. |
60
59
  | `larrylint/definition-description` | Definitions have a `studio.description`, which Studio shows in its section picker and AI agents read through Laioutr's MCP server. |
61
- | `larrylint/single-root` | Sections and blocks render one root element, also through a component they wrap. Otherwise Vue drops the `data-lfc-*` markers frontend-core adds. |
60
+ | `larrylint/single-root` | Sections and blocks render one root element, also through a component they wrap. Otherwise Vue drops the `data-lfc-*` attributes Studio needs to select them in the preview. |
62
61
  | `larrylint/slot-children` | Nothing counts slot children with `.length`: frontend-core passes all blocks of a slot as one Fragment. |
63
62
  | `larrylint/reserved-field-names` | No top-level schema fields named `key`, `ref`, `ref_for`, `ref_key`, `class` or `style`, which Vue handles itself, and no `slots` on sections, which frontend-core overwrites. |
64
- | `larrylint/field-name-case` | Top-level schema field names have no `-` and don't start with `$`: Vue camelizes the one and rejects the other. |
63
+ | `larrylint/field-name-case` | Top-level schema field names have no `-` and don't start with `$`, which Vue renames or rejects as props. |
65
64
  | `larrylint/required-fields` | Schema fields have no `required`, which Studio ignores. Give them a `default` instead. |
66
65
  | `larrylint/unused-fields` | The component reads every schema field it defines, directly, through the section it's a block of, or in another field's `if`, so editors don't get fields that do nothing. |
67
- | `larrylint/dead-fallbacks` | No `??` fallbacks that never apply: frontend-core fills unset pickers with their first option, checkboxes with `false` and text fields with `''`. |
66
+ | `larrylint/dead-fallbacks` | No `??` fallbacks that are never used: frontend-core fills unset pickers with their first option, content alignments with the center, checkboxes with `false` and text fields with `''`. |
68
67
 
69
68
  ### Frontend
70
69
 
71
70
  | Rule | Description |
72
71
  | --- | --- |
73
72
  | `larrylint/button-type` | No `type` on the ui-kit buttons: they always render their `button-type` prop, so `type="submit"` silently renders a dead button. Autofixable. |
74
- | `larrylint/mutation-errors` | An awaited or dropped `mutateAsync()` handles its error. Otherwise a failed mutation replaces the whole section or block with frontend-core's "Retry" state, or ends up as an unhandled rejection. |
75
- | `larrylint/resolve-result` | Nothing tests the result of `linkResolver.resolve()`: a link it can't resolve comes back as a `#…` fallback, not as an empty value. |
73
+ | `larrylint/mutation-errors` | An awaited or dropped `mutateAsync()` handles its error. Otherwise a failed mutation replaces the whole section or block with an error, or nothing handles it. |
74
+ | `larrylint/resolve-result` | Nothing checks the result of `linkResolver.resolve()`: a link it can't resolve comes back as a `#…` fallback, not as an empty value. |
76
75
  | `larrylint/hand-built-links` | Links to pages come from `linkResolver`, not from paths like `` `/hotels/${slug}` ``: page paths are set per page and language in Studio, and each market adds its own prefix, like `/en`. |
77
76
  | `larrylint/internal-anchors` | Internal and resolved links use `<NuxtLink>`: a plain `<a>` reloads the page and breaks Studio's navigation sync. |
78
77
 
79
78
  ## Layout
80
79
 
81
- larrylint expects the layout of Laioutr's [app starter](https://github.com/laioutr/app-starter):
80
+ larrylint follows the runtime layout from Laioutr's [coding standards](https://docs.laioutr.com/apps/app-development/coding-standards):
82
81
 
83
82
  ```
84
83
  src/
85
- ├── module.ts # build time
84
+ ├── module.ts # registers sections, blocks, orchestr handlers and plugins
86
85
  └── runtime/
87
- ├── app/
88
- │ ├── sections/ # defineSection() components, or section/
89
- │ ├── blocks/ # defineBlock() components, or block/
90
- │ ├── components/
91
- │ ├── composables/
92
- │ ├── overrides/ # replacements for upstream components
93
- │ ├── plugins/
94
- │ ├── shared-fields/ # schema fields sections and blocks share
95
- │ ├── theme/
86
+ ├── app/ # client code
87
+ │ ├── components/ # UI components
88
+ │ ├── sections/ # defineSection() components
89
+ │ └── blocks/ # defineBlock() components
90
+ ├── server/ # server code
91
+ │ ├── client/ # API client factories
92
+ │ ├── const/
93
+ │ ├── mappers/
94
+ │ ├── middleware/ # defineOrchestr() with extendRequest()
95
+ │ ├── orchestr/ # *.query.ts, *.resolver.ts, *.link.ts, *.action.ts, *.template.ts, *.page-index.ts
96
+ │ ├── orchestr-helper/ # helper logic for handlers
96
97
  │ └── utils/
97
- ├── server/
98
- │ ├── orchestr/
99
- │ │ ├── <entity>/ # *.query.ts, *.resolver.ts, *.link.ts, *.action.ts, *.template.ts, *.page-index.ts
100
- │ │ └── plugins/
101
- │ ├── middleware/ # orchestr middleware and the builders handlers use
102
- │ ├── client/ # API clients, nothing else
103
- │ ├── api/ # server routes, or routes/
104
- │ ├── plugins/ # Nitro plugins
105
- │ ├── media-library/ # or media-libraries/
106
- │ └── utils/
107
- └── shared/ # code for both the app and the server
98
+ └── shared/ # code for both sides, like orchestr tokens
108
99
  ```
109
100
 
101
+ Where your sections, blocks and handlers live, larrylint reads from your `module.ts`: the folders and globs you pass to `registerLaioutrApp()`, and the plugins you pass to `addPlugin()`. So other folders work too, like `section/` and `block/` in Laioutr's own ui-app. Without a `module.ts` it can read, larrylint assumes the folders of Laioutr's [app starter](https://github.com/laioutr/app-starter): `app/sections/`, `app/blocks/` and `server/orchestr/`.
102
+
103
+ Besides that, larrylint only relies on the names `app/components/` and `server/client/`, and on the split into `app/`, `server/` and `shared/`.
104
+
110
105
  ## Baseline
111
106
 
112
107
  On an existing codebase, `larrylint init` records all current violations in `larrylint-baseline.json`, counted per file and rule. A file stays quiet as long as it has no more violations of a rule than recorded. Once it gets more, all of them show again, like ESLint's bulk suppressions. The baseline applies in your editor, in `eslint .` and in the CLI.
@@ -2,8 +2,8 @@ import { BASELINE_FILE, countViolations, writeBaseline } from "./preset.mjs";
2
2
  import { cwdArgs } from "../cli/index.mjs";
3
3
  import { check } from "./check.mjs";
4
4
  import process from "node:process";
5
- import { join, resolve } from "pathe";
6
5
  import { existsSync } from "node:fs";
6
+ import { join, resolve } from "pathe";
7
7
  import { defineCommand } from "citty";
8
8
  import { consola } from "consola";
9
9
  var check_default = defineCommand({
@@ -2,8 +2,8 @@ import { BASELINE_FILE, countViolations, writeBaseline } from "./preset.mjs";
2
2
  import { cwdArgs } from "../cli/index.mjs";
3
3
  import { check } from "./check.mjs";
4
4
  import process from "node:process";
5
- import { join, relative, resolve } from "pathe";
6
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join, relative, resolve } from "pathe";
7
7
  import { defineCommand } from "citty";
8
8
  import { consola } from "consola";
9
9
  import { addDevDependency, detectPackageManager } from "nypm";
@@ -1,4 +1,4 @@
1
1
  var name = "larrylint";
2
- var version = "0.2.0";
2
+ var version = "0.2.2";
3
3
  var description = "Opinionated rules for Laioutr apps, as an ESLint preset and a CLI.";
4
4
  export { description, name, version };
@@ -1,8 +1,8 @@
1
1
  import { name, version } from "./package.mjs";
2
2
  import process from "node:process";
3
3
  import { loadConfig } from "c12";
4
- import { dirname, join, normalize, parse, relative, resolve } from "pathe";
5
4
  import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, matchesGlob, normalize, parse, relative, resolve } from "pathe";
6
6
  import tsParser, { parse as parse$1 } from "@typescript-eslint/parser";
7
7
  import { parse as parse$2 } from "vue-eslint-parser";
8
8
  function defineLarrylintConfig(config) {
@@ -19,83 +19,6 @@ async function loadLarrylintConfig(cwd = process.cwd()) {
19
19
  });
20
20
  return { heavyPackages: config.heavyPackages ?? [] };
21
21
  }
22
- const HANDLER_FILE = /\.(query|resolver|link|action|templates?|page-index)(?:\.[cm]?[jt]s)?$/;
23
- const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
24
- const APP_FOLDERS = {
25
- "sections": "section",
26
- "section": "section",
27
- "blocks": "block",
28
- "block": "block",
29
- "components": "component",
30
- "composables": "composable",
31
- "utils": "app-util",
32
- "plugins": "app-plugin",
33
- "overrides": "override",
34
- "theme": "theme",
35
- "shared-fields": "shared-field"
36
- };
37
- const SERVER_FOLDERS = {
38
- "middleware": "middleware",
39
- "client": "client",
40
- "utils": "server-util",
41
- "api": "route",
42
- "routes": "route",
43
- "plugins": "nitro-plugin",
44
- "media-library": "media-library",
45
- "media-libraries": "media-library"
46
- };
47
- function classify(file) {
48
- const absolute = normalize(file);
49
- const test = TEST_FILE.test(absolute);
50
- const runtimeIndex = absolute.lastIndexOf("/src/runtime/");
51
- if (runtimeIndex !== -1) {
52
- const root = absolute.slice(0, runtimeIndex);
53
- return {
54
- root,
55
- path: absolute.slice(root.length + 1),
56
- test,
57
- ...classifyRuntime(absolute.slice(runtimeIndex + 13).split("/"))
58
- };
59
- }
60
- const srcIndex = absolute.lastIndexOf("/src/");
61
- if (srcIndex === -1) return;
62
- const root = absolute.slice(0, srcIndex);
63
- const path = absolute.slice(root.length + 1);
64
- return {
65
- root,
66
- path,
67
- side: path.startsWith("src/types/") ? "other" : "build",
68
- test
69
- };
70
- }
71
- function classifyRuntime(parts) {
72
- const [side, folder = "", ...rest] = parts;
73
- if (parts.length < 2 || side !== "app" && side !== "server" && side !== "shared") return { side: "other" };
74
- if (side === "shared") return {
75
- side,
76
- kind: "shared"
77
- };
78
- if (side === "app") return {
79
- side,
80
- kind: APP_FOLDERS[folder] ?? "other"
81
- };
82
- if (folder === "orchestr") {
83
- if (rest.length > 1 && rest[0] === "plugins") return {
84
- side,
85
- kind: "orchestr-plugin"
86
- };
87
- const handler = HANDLER_FILE.exec(parts.at(-1))?.[1]?.replace("templates", "template");
88
- return {
89
- side,
90
- kind: handler ? "handler" : "orchestr-file",
91
- handler
92
- };
93
- }
94
- return {
95
- side,
96
- kind: SERVER_FOLDERS[folder] ?? "other"
97
- };
98
- }
99
22
  const EXTENSIONS = [
100
23
  "",
101
24
  ".ts",
@@ -107,6 +30,7 @@ const EXTENSIONS = [
107
30
  "/index.ts",
108
31
  "/index.js"
109
32
  ];
33
+ const SOURCE_EXTENSION = /\.(?:vue|[cm]?[jt]sx?)$/;
110
34
  const directories = /* @__PURE__ */ new Map();
111
35
  function cachedReader(parse) {
112
36
  const cache = /* @__PURE__ */ new Map();
@@ -139,6 +63,9 @@ function isDirectory(path) {
139
63
  }
140
64
  return result;
141
65
  }
66
+ function withoutExtension(path) {
67
+ return path.replace(SOURCE_EXTENSION, "");
68
+ }
142
69
  function resolveImport(importer, source) {
143
70
  if (!source.startsWith("./") && !source.startsWith("../")) return;
144
71
  const target = resolve(dirname(importer), source.split("?")[0]);
@@ -206,6 +133,182 @@ function applyBaseline(cwd, results) {
206
133
  improved
207
134
  };
208
135
  }
136
+ function findProperty(object, key) {
137
+ for (const entry of object.properties) if (entry.type === "Property" && !entry.computed && (entry.key.type === "Identifier" && entry.key.name === key || entry.key.type === "Literal" && entry.key.value === key)) return entry.value;
138
+ }
139
+ function findStringProperty(object, key) {
140
+ const value = findProperty(object, key);
141
+ return value?.type === "Literal" && typeof value.value === "string" ? {
142
+ value: value.value,
143
+ node: value
144
+ } : void 0;
145
+ }
146
+ function objectElements(node) {
147
+ if (node?.type !== "ArrayExpression") return [];
148
+ return node.elements.filter((element) => element?.type === "ObjectExpression");
149
+ }
150
+ const parseFile = cachedReader((text) => {
151
+ try {
152
+ return parse$1(text, {
153
+ range: true,
154
+ loc: true
155
+ });
156
+ } catch {
157
+ return;
158
+ }
159
+ });
160
+ const SKIPPED = /* @__PURE__ */ new Set([
161
+ "parent",
162
+ "loc",
163
+ "range",
164
+ "tokens",
165
+ "comments"
166
+ ]);
167
+ function children(node) {
168
+ const result = [];
169
+ for (const [key, value] of Object.entries(node)) {
170
+ if (SKIPPED.has(key)) continue;
171
+ for (const child of Array.isArray(value) ? value : [value]) if (typeof child === "object" && child !== null && typeof child.type === "string") result.push(child);
172
+ }
173
+ return result;
174
+ }
175
+ function walk(node, enter) {
176
+ if (enter(node) === false) return;
177
+ for (const child of children(node)) walk(child, enter);
178
+ }
179
+ const STARTER = {
180
+ sections: ["app/sections"],
181
+ blocks: ["app/blocks"],
182
+ orchestr: ["server/orchestr"],
183
+ plugins: /* @__PURE__ */ new Set(),
184
+ references: /* @__PURE__ */ new Set()
185
+ };
186
+ const registrations = /* @__PURE__ */ new WeakMap();
187
+ function readRegistration(root) {
188
+ const program = parseFile(join(root, "src/module.ts"));
189
+ if (!program) return STARTER;
190
+ let registration = registrations.get(program);
191
+ if (!registration) {
192
+ registration = readModule(program);
193
+ registrations.set(program, registration);
194
+ }
195
+ return registration;
196
+ }
197
+ function readModule(program) {
198
+ const apps = [];
199
+ const plugins = /* @__PURE__ */ new Set();
200
+ const references = /* @__PURE__ */ new Set();
201
+ walk(program, (node) => {
202
+ const reference = node.type === "Literal" || node.type === "TemplateLiteral" ? runtimePath(node) : void 0;
203
+ if (reference) references.add(withoutExtension(reference));
204
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return;
205
+ const [argument] = node.arguments;
206
+ if (node.callee.name === "registerLaioutrApp" && argument?.type === "ObjectExpression") apps.push(argument);
207
+ if (node.callee.name === "addPlugin" && argument && argument.type !== "SpreadElement") {
208
+ const src = argument.type === "ObjectExpression" ? findProperty(argument, "src") : argument;
209
+ const plugin = src && runtimePath(src);
210
+ if (plugin) plugins.add(withoutExtension(plugin));
211
+ }
212
+ });
213
+ const [app] = apps;
214
+ return {
215
+ sections: app ? readEntries(app, "sections", STARTER.sections) : STARTER.sections,
216
+ blocks: app ? readEntries(app, "blocks", STARTER.blocks) : STARTER.blocks,
217
+ orchestr: app ? readEntries(app, "orchestrDirs", STARTER.orchestr) : STARTER.orchestr,
218
+ plugins,
219
+ references
220
+ };
221
+ }
222
+ function readEntries(app, option, fallback) {
223
+ const value = findProperty(app, option);
224
+ if (!value) return [];
225
+ const paths = value.type === "ArrayExpression" ? value.elements.map((element) => element && element.type !== "SpreadElement" ? runtimePath(element) : void 0) : [void 0];
226
+ const known = paths.filter((path) => path !== void 0);
227
+ return known.length > 0 || paths.length === 0 ? known : fallback;
228
+ }
229
+ function runtimePath(node) {
230
+ const parts = [];
231
+ walk(node, (child) => {
232
+ if (child.type === "Literal" && typeof child.value === "string") parts.push(child.value);
233
+ else if (child.type === "TemplateElement" && child.value.cooked) parts.push(child.value.cooked);
234
+ });
235
+ const path = normalize(parts.join("/")).replace(/^(?:.*\/)?runtime\//, "").replace(/^\/|\/$/g, "");
236
+ return /^(?:app|server|shared)\//.test(path) ? path : void 0;
237
+ }
238
+ function isRegistered(path, entries) {
239
+ return entries.some((entry) => matchesGlob(path, entry.includes(".vue") ? entry : `${entry}/**/*.vue`));
240
+ }
241
+ function entryFolder(entry) {
242
+ const parts = entry.split("/");
243
+ const end = parts.findIndex((part) => /[*?{[]/.test(part) || part.endsWith(".vue"));
244
+ return (end === -1 ? parts : parts.slice(0, end)).join("/");
245
+ }
246
+ function isReferenced(file) {
247
+ return readRegistration(file.root).references.has(withoutExtension(file.path.replace(/^src\/runtime\//, "")));
248
+ }
249
+ const HANDLER_FILE = /\.(query|resolver|link|action|templates?|page-index)(?:\.[cm]?[jt]s)?$/;
250
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
251
+ const APP_FOLDERS = { components: "component" };
252
+ const SERVER_FOLDERS = { client: "client" };
253
+ function classify(file) {
254
+ const absolute = normalize(file);
255
+ const test = TEST_FILE.test(absolute);
256
+ const runtimeIndex = absolute.lastIndexOf("/src/runtime/");
257
+ if (runtimeIndex !== -1) {
258
+ const root = absolute.slice(0, runtimeIndex);
259
+ return {
260
+ root,
261
+ path: absolute.slice(root.length + 1),
262
+ test,
263
+ ...classifyRuntime(absolute.slice(runtimeIndex + 13), readRegistration(root))
264
+ };
265
+ }
266
+ const srcIndex = absolute.lastIndexOf("/src/");
267
+ if (srcIndex === -1) return;
268
+ const root = absolute.slice(0, srcIndex);
269
+ const path = absolute.slice(root.length + 1);
270
+ return {
271
+ root,
272
+ path,
273
+ side: path.startsWith("src/types/") ? "other" : "build",
274
+ test
275
+ };
276
+ }
277
+ function classifyRuntime(path, registration) {
278
+ const [side, folder] = path.split("/");
279
+ if (!folder || side !== "app" && side !== "server" && side !== "shared") return { side: "other" };
280
+ if (side === "shared") return { side };
281
+ if (side === "app") {
282
+ if (isRegistered(path, registration.sections)) return {
283
+ side,
284
+ kind: "section"
285
+ };
286
+ if (isRegistered(path, registration.blocks)) return {
287
+ side,
288
+ kind: "block"
289
+ };
290
+ return {
291
+ side,
292
+ kind: registration.plugins.has(withoutExtension(path)) ? "app-plugin" : APP_FOLDERS[folder] ?? "other"
293
+ };
294
+ }
295
+ const orchestr = registration.orchestr.find((dir) => path.startsWith(`${dir}/`));
296
+ if (!orchestr) return {
297
+ side,
298
+ kind: SERVER_FOLDERS[folder] ?? "other"
299
+ };
300
+ const [first, ...rest] = path.slice(orchestr.length + 1).split("/");
301
+ if (first === "plugins" && rest.length > 0) return {
302
+ side,
303
+ kind: "orchestr-plugin"
304
+ };
305
+ const handler = HANDLER_FILE.exec(path)?.[1]?.replace("templates", "template");
306
+ return {
307
+ side,
308
+ kind: handler ? "handler" : "orchestr-file",
309
+ handler
310
+ };
311
+ }
209
312
  function defineRule(rule) {
210
313
  return {
211
314
  meta: rule.meta,
@@ -331,25 +434,6 @@ function isTested(node) {
331
434
  default: return false;
332
435
  }
333
436
  }
334
- const SKIPPED = /* @__PURE__ */ new Set([
335
- "parent",
336
- "loc",
337
- "range",
338
- "tokens",
339
- "comments"
340
- ]);
341
- function children(node) {
342
- const result = [];
343
- for (const [key, value] of Object.entries(node)) {
344
- if (SKIPPED.has(key)) continue;
345
- for (const child of Array.isArray(value) ? value : [value]) if (typeof child === "object" && child !== null && typeof child.type === "string") result.push(child);
346
- }
347
- return result;
348
- }
349
- function walk(node, enter) {
350
- if (enter(node) === false) return;
351
- for (const child of children(node)) walk(child, enter);
352
- }
353
437
  function isFunction(node) {
354
438
  return node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression";
355
439
  }
@@ -396,11 +480,11 @@ function canReturnNullish(fn, value) {
396
480
  var mutations_default = defineRule({
397
481
  meta: {
398
482
  type: "problem",
399
- docs: { description: "Require error handling where a mutateAsync() promise is awaited or dropped, since a rejected mutation takes down its whole section or block." },
483
+ docs: { description: "Require error handling for awaited or dropped `mutateAsync()` calls" },
400
484
  schema: [],
401
485
  messages: {
402
- unhandled: "If this mutation fails, frontend-core replaces the whole section or block with its \"Retry\" state, unless every caller catches the error. Catch it here with try/catch or .catch().",
403
- dropped: "Nothing handles this promise, so a failed mutation ends up as an unhandled rejection without any feedback. Catch it with try/catch or .catch()."
486
+ unhandled: "If this mutation fails, an error replaces the whole section or block. Catch it with try/catch or .catch().",
487
+ dropped: "Nothing catches errors of this mutation. Add .catch(), or await it in a try/catch."
404
488
  }
405
489
  },
406
490
  applies: (file) => file.side === "app",
@@ -450,16 +534,6 @@ function isRouter(node) {
450
534
  const name = nameOf(node.type === "CallExpression" ? node.callee : node);
451
535
  return node.type === "CallExpression" ? name === "useRouter" : name === "router" || name === "$router";
452
536
  }
453
- const parseFile = cachedReader((text) => {
454
- try {
455
- return parse$1(text, {
456
- range: true,
457
- loc: true
458
- });
459
- } catch {
460
- return;
461
- }
462
- });
463
537
  function isTypeOnly(node) {
464
538
  const { importKind, exportKind } = node;
465
539
  if (importKind === "type" || exportKind === "type") return true;
@@ -607,9 +681,9 @@ function rendersTag(element, tag) {
607
681
  var anchors_default = defineRule({
608
682
  meta: {
609
683
  type: "problem",
610
- docs: { description: "Disallow plain `<a>` tags for internal and resolved links, which reload the page and break Studio's navigation sync." },
684
+ docs: { description: "Disallow plain `<a>` tags for internal links" },
611
685
  schema: [],
612
- messages: { anchor: "A plain <a> reloads the whole page and breaks Studio's navigation sync. Use <NuxtLink> for internal links." }
686
+ messages: { anchor: "A plain <a> reloads the whole page. Use <NuxtLink> for internal links." }
613
687
  },
614
688
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
615
689
  create: ({ report, visitTemplate }) => {
@@ -646,9 +720,9 @@ const ROUTER_METHODS = /* @__PURE__ */ new Set(["push", "replace"]);
646
720
  var paths_default = defineRule({
647
721
  meta: {
648
722
  type: "problem",
649
- docs: { description: "Disallow hand-built paths to pages, which miss the per-language paths and market prefixes Studio sets." },
723
+ docs: { description: "Disallow page paths built by hand" },
650
724
  schema: [],
651
- messages: { path: "Page paths are set per page and language in Studio, and each market adds its own prefix, e.g. /en, so this path can lead to the wrong page. Resolve the link with linkResolver instead." }
725
+ messages: { path: "Hand-built page paths differ per language and market. Use linkResolver instead." }
652
726
  },
653
727
  applies: (file) => file.side === "app",
654
728
  create: ({ report, visitTemplate }) => {
@@ -681,11 +755,11 @@ var paths_default = defineRule({
681
755
  var resolve_default = defineRule({
682
756
  meta: {
683
757
  type: "problem",
684
- docs: { description: "Disallow testing the result of linkResolver.resolve(), which falls back to a '#…' string instead of an empty value." },
758
+ docs: { description: "Disallow checking the result of `linkResolver.resolve()`" },
685
759
  schema: [],
686
760
  messages: {
687
- tested: "linkResolver.resolve() returns a '#…' fallback instead of an empty value when it can't resolve a link, so this check doesn't catch broken links. Use resolveOrThrow() (frontend-core 0.42+) in a try/catch to tell them apart.",
688
- branch: "When it can't resolve the link, linkResolver.resolve() returns a '#…' fallback here, so the checks on {{name}} treat a broken link as a working one. Use resolveOrThrow() (frontend-core 0.42+) in a try/catch and return undefined instead."
761
+ tested: "linkResolver.resolve() never returns an empty value, so this check misses broken links. Use resolveOrThrow() in a try/catch instead.",
762
+ branch: "linkResolver.resolve() never returns an empty value, so the checks on '{{name}}' miss broken links. Use resolveOrThrow() in a try/catch instead."
689
763
  }
690
764
  },
691
765
  applies: (file) => file.side === "app",
@@ -765,9 +839,9 @@ var button_default = defineRule({
765
839
  meta: {
766
840
  type: "problem",
767
841
  fixable: "code",
768
- docs: { description: "Disallow `type` on the ui-kit buttons, which silently render their `button-type` prop instead." },
842
+ docs: { description: "Disallow `type` on ui-kit buttons" },
769
843
  schema: [],
770
- messages: { buttonType: "<{{tag}}> ignores type and always renders its button-type prop, which defaults to \"button\". Use button-type." }
844
+ messages: { buttonType: "<{{tag}}> ignores type. Use button-type instead." }
771
845
  },
772
846
  applies: (file) => file.path.endsWith(".vue"),
773
847
  create: ({ report, visitTemplate }) => {
@@ -861,11 +935,11 @@ const MANAGED = {
861
935
  var cookies_default = defineRule({
862
936
  meta: {
863
937
  type: "problem",
864
- docs: { description: "Write cookies and headers only where orchestr allows it: in extendRequest() and action handlers, with frontend-core's managed cookie functions." },
938
+ docs: { description: "Require cookies and headers to be set in `extendRequest()` or action handlers, with frontend-core's managed cookie functions" },
865
939
  schema: [],
866
940
  messages: {
867
- streamed: "Orchestr may have sent the response headers by the time {{name}}() runs here, so the browser never gets it. Write cookies and headers in extendRequest() or an action handler.",
868
- managed: "Use {{managed}}() instead. It applies Laioutr's cookie policy, which the Studio preview needs (SameSite=None and Partitioned), and {{name}}() skips it."
941
+ streamed: "The headers may already be sent when {{name}}() runs here. Call it in extendRequest() or an action handler instead.",
942
+ managed: "Use {{managed}}() instead, so it also works in the Studio preview."
869
943
  }
870
944
  },
871
945
  applies: (file) => file.side === "server",
@@ -899,12 +973,12 @@ const FILE_START = {
899
973
  column: 0
900
974
  }
901
975
  };
902
- var files_default$1 = defineRule({
976
+ var files_default = defineRule({
903
977
  meta: {
904
978
  type: "problem",
905
- docs: { description: "Keep orchestr/ to handler files, since Laioutr loads every file in it as a Nitro plugin." },
979
+ docs: { description: "Disallow files other than handlers in `orchestr/`" },
906
980
  schema: [],
907
- messages: { notAHandler: "Laioutr loads every file in orchestr/ as a Nitro plugin, so a helper here runs at startup or breaks the build. Move it to server/utils/, and name handlers *.query.ts, *.resolver.ts, *.link.ts, *.action.ts, *.template.ts or *.page-index.ts." }
981
+ messages: { notAHandler: "Only handlers belong in orchestr/. Move this file to server/orchestr-helper/, or give it a handler suffix like .query.ts or .action.ts." }
908
982
  },
909
983
  applies: (file) => file.kind === "orchestr-file",
910
984
  create: ({ report }) => ({ Program: () => {
@@ -917,9 +991,9 @@ var files_default$1 = defineRule({
917
991
  var exports_default = defineRule({
918
992
  meta: {
919
993
  type: "problem",
920
- docs: { description: "Require handler files to export their handler as default." },
994
+ docs: { description: "Require handler files to export their handler as default" },
921
995
  schema: [],
922
- messages: { missingDefault: "Laioutr loads every file in orchestr/ as a Nitro plugin from its default export, so without one the build fails. Export the handler as default." }
996
+ messages: { missingDefault: "Export the handler as default, or it will fail the build." }
923
997
  },
924
998
  applies: (file) => file.kind === "handler",
925
999
  create: ({ report }) => {
@@ -940,47 +1014,14 @@ var exports_default = defineRule({
940
1014
  };
941
1015
  }
942
1016
  });
943
- const MIDDLEWARE = /* @__PURE__ */ new Set([
944
- "defineOrchestr",
945
- "defineEventHandler",
946
- "eventHandler",
947
- "defineRequestMiddleware",
948
- "fromNodeMiddleware"
949
- ]);
950
- var files_default = defineRule({
951
- meta: {
952
- type: "suggestion",
953
- docs: { description: "Keep server/middleware/ to orchestr and Nitro middleware." },
954
- schema: [],
955
- messages: { helper: "server/middleware/ holds orchestr and Nitro middleware. Move helpers like this to server/utils/." }
956
- },
957
- applies: (file) => file.kind === "middleware",
958
- create: ({ report }) => {
959
- let middleware = false;
960
- return {
961
- "Identifier": (node) => {
962
- middleware ||= MIDDLEWARE.has(node.name);
963
- },
964
- "CallExpression": (node) => {
965
- middleware ||= readMiddleware(node) !== void 0;
966
- },
967
- "Program:exit": () => {
968
- if (!middleware) report({
969
- loc: FILE_START,
970
- messageId: "helper"
971
- });
972
- }
973
- };
974
- }
975
- });
976
1017
  var throws_default = defineRule({
977
1018
  meta: {
978
1019
  type: "problem",
979
- docs: { description: "Disallow throwing in extendRequest(), which runs before every query, so a throw takes down every page." },
1020
+ docs: { description: "Disallow throwing in `extendRequest()`" },
980
1021
  schema: [],
981
1022
  messages: {
982
- throws: "extendRequest() runs before every query, so this throw takes down every page. Return a stand-in instead, e.g. a client that fails only when it's used.",
983
- throwsInCall: "{{name}}() can throw ({{location}}). extendRequest() runs before every query, so that takes down every page. Catch it here or return a stand-in."
1023
+ throws: "A throw in extendRequest() breaks every page. Throw later instead, e.g. when the client is used.",
1024
+ throwsInCall: "{{name}}() can throw ({{location}}), and a throw in extendRequest() breaks every page. Catch it here."
984
1025
  }
985
1026
  },
986
1027
  applies: (file) => file.side === "server",
@@ -1034,9 +1075,9 @@ function canonicalNamespaces(root) {
1034
1075
  var namespaces_default = defineRule({
1035
1076
  meta: {
1036
1077
  type: "problem",
1037
- docs: { description: "Keep your own token ids out of the namespaces of Laioutr's canonical types." },
1078
+ docs: { description: "Disallow your own token ids in the namespaces of Laioutr's canonical types" },
1038
1079
  schema: [],
1039
- messages: { canonical: "'{{namespace}}/' belongs to Laioutr's canonical types, and a later canonical-types release or another app can take this id. Token metadata is last-write-wins, so put your own tokens under your app's namespace." }
1080
+ messages: { canonical: "'{{namespace}}/' belongs to Laioutr's canonical types. Use your app's own namespace." }
1040
1081
  },
1041
1082
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
1042
1083
  create: ({ file, report }) => ({ CallExpression: (node) => {
@@ -1050,28 +1091,14 @@ var namespaces_default = defineRule({
1050
1091
  });
1051
1092
  } })
1052
1093
  });
1053
- function findProperty(object, key) {
1054
- for (const entry of object.properties) if (entry.type === "Property" && !entry.computed && (entry.key.type === "Identifier" && entry.key.name === key || entry.key.type === "Literal" && entry.key.value === key)) return entry.value;
1055
- }
1056
- function findStringProperty(object, key) {
1057
- const value = findProperty(object, key);
1058
- return value?.type === "Literal" && typeof value.value === "string" ? {
1059
- value: value.value,
1060
- node: value
1061
- } : void 0;
1062
- }
1063
- function objectElements(node) {
1064
- if (node?.type !== "ArrayExpression") return [];
1065
- return node.elements.filter((element) => element?.type === "ObjectExpression");
1066
- }
1067
1094
  const DEFINERS = {
1068
1095
  defineSection: {
1069
1096
  kind: "section",
1070
- folder: "sections"
1097
+ option: "sections"
1071
1098
  },
1072
1099
  defineBlock: {
1073
1100
  kind: "block",
1074
- folder: "blocks"
1101
+ option: "blocks"
1075
1102
  }
1076
1103
  };
1077
1104
  const PICKERS = /* @__PURE__ */ new Set([
@@ -1079,10 +1106,10 @@ const PICKERS = /* @__PURE__ */ new Set([
1079
1106
  "radio",
1080
1107
  "toggle_button"
1081
1108
  ]);
1082
- const TEXTS$1 = /* @__PURE__ */ new Set([
1109
+ const TEXTS = /* @__PURE__ */ new Set([
1083
1110
  "text",
1084
1111
  "textarea",
1085
- "secret"
1112
+ "richtext"
1086
1113
  ]);
1087
1114
  function readDefinition(call) {
1088
1115
  if (call.callee.type !== "Identifier" || !Object.hasOwn(DEFINERS, call.callee.name)) return;
@@ -1138,7 +1165,7 @@ function expectedDefiner(kind) {
1138
1165
  }
1139
1166
  function fillValue(field, program, filename) {
1140
1167
  if (field.type === "checkbox") return { value: field.as === "visibility" };
1141
- if (TEXTS$1.has(field.type ?? "")) return { value: "" };
1168
+ if (TEXTS.has(field.type ?? "")) return { value: "" };
1142
1169
  if (field.type === "content_alignment") {
1143
1170
  const axis = findStringProperty(field.node, "axis")?.value;
1144
1171
  return { value: !axis || axis === "both" ? "center-center" : "center" };
@@ -1155,31 +1182,33 @@ function fillValue(field, program, filename) {
1155
1182
  var description_default = defineRule({
1156
1183
  meta: {
1157
1184
  type: "suggestion",
1158
- docs: { description: "Require a studio.description on section and block definitions." },
1185
+ docs: { description: "Require a `studio.description` on section and block definitions" },
1159
1186
  schema: [],
1160
- messages: { description: "Studio shows studio.description in its section picker, and AI agents read it through Laioutr's MCP server. Add one." }
1187
+ messages: { description: "Add a studio.description, so editors and AI agents know what this {{kind}} is for." }
1161
1188
  },
1162
1189
  applies: (file) => file.side === "app",
1163
1190
  create: ({ report }) => ({ CallExpression: (node) => {
1164
- const options = readDefinition(node)?.options;
1165
- if (!options) return;
1166
- const studio = findProperty(options, "studio");
1191
+ const definition = readDefinition(node);
1192
+ if (!definition?.options) return;
1193
+ const studio = findProperty(definition.options, "studio");
1167
1194
  if (studio && studio.type !== "ObjectExpression") return;
1168
1195
  const description = studio && findProperty(studio, "description");
1169
1196
  if (!description || description.type === "Literal" && String(description.value ?? "").trim() === "") report({
1170
1197
  node: node.callee,
1171
- messageId: "description"
1198
+ messageId: "description",
1199
+ data: { kind: DEFINERS[definition.definer].kind }
1172
1200
  });
1173
1201
  } })
1174
1202
  });
1175
1203
  var folder_default = defineRule({
1176
1204
  meta: {
1177
1205
  type: "problem",
1178
- docs: { description: "Keep defineSection() in app/sections/ and defineBlock() in app/blocks/, and every .vue there defined." },
1206
+ docs: { description: "Require sections and blocks in the folders `module.ts` registers for them, and a definition in every `.vue` registered there" },
1179
1207
  schema: [],
1180
1208
  messages: {
1181
- notDefined: "Laioutr loads every .vue in {{folder}}/ as a {{kind}}, but this file has no {{definer}}(), so frontend-core warns about it. Move it to components/.",
1182
- wrongFolder: "{{definer}}() belongs in app/{{folder}}/."
1209
+ notDefined: "This file is registered as a {{kind}}, so it needs {{definer}}(). Add it, or move the file to components/.",
1210
+ wrongFolder: "{{definer}}() belongs in {{folder}}/. Move the file there.",
1211
+ notRegistered: "module.ts registers no {{kind}}s, so this {{kind}} never shows up in Studio. Add its folder to registerLaioutrApp()."
1183
1212
  }
1184
1213
  },
1185
1214
  applies: (file) => file.side === "app",
@@ -1189,31 +1218,33 @@ var folder_default = defineRule({
1189
1218
  "CallExpression": (node) => {
1190
1219
  const definition = readDefinition(node);
1191
1220
  if (!definition) return;
1192
- const { kind, folder } = DEFINERS[definition.definer];
1221
+ const { kind, option } = DEFINERS[definition.definer];
1193
1222
  definers.add(definition.definer);
1194
- if (file.kind !== kind && file.kind !== "override") report({
1223
+ if (file.kind === kind || isReferenced(file)) return;
1224
+ const [entry] = readRegistration(file.root)[option];
1225
+ report(entry ? {
1195
1226
  node: node.callee,
1196
1227
  messageId: "wrongFolder",
1197
1228
  data: {
1198
1229
  definer: definition.definer,
1199
- folder
1230
+ folder: entryFolder(entry)
1200
1231
  }
1232
+ } : {
1233
+ node: node.callee,
1234
+ messageId: "notRegistered",
1235
+ data: { kind }
1201
1236
  });
1202
1237
  },
1203
1238
  "Program:exit": () => {
1204
1239
  const expected = expectedDefiner(file.kind);
1205
- if (expected && file.path.endsWith(".vue") && !definers.has(expected)) {
1206
- const { kind, folder } = DEFINERS[expected];
1207
- report({
1208
- loc: FILE_START,
1209
- messageId: "notDefined",
1210
- data: {
1211
- folder,
1212
- kind,
1213
- definer: expected
1214
- }
1215
- });
1216
- }
1240
+ if (expected && file.path.endsWith(".vue") && !definers.has(expected)) report({
1241
+ loc: FILE_START,
1242
+ messageId: "notDefined",
1243
+ data: {
1244
+ kind: DEFINERS[expected].kind,
1245
+ definer: expected
1246
+ }
1247
+ });
1217
1248
  }
1218
1249
  };
1219
1250
  }
@@ -1221,7 +1252,7 @@ var folder_default = defineRule({
1221
1252
  var name_default = defineRule({
1222
1253
  meta: {
1223
1254
  type: "problem",
1224
- docs: { description: "Require the `component` of a section or block definition to match its file name." },
1255
+ docs: { description: "Require the `component` of a definition to match its file name" },
1225
1256
  schema: [],
1226
1257
  messages: { componentName: "component: '{{actual}}' must match the file name '{{expected}}'." }
1227
1258
  },
@@ -1239,20 +1270,23 @@ var name_default = defineRule({
1239
1270
  });
1240
1271
  } })
1241
1272
  });
1242
- const TEXTS = /* @__PURE__ */ new Set([
1243
- "text",
1244
- "textarea",
1245
- "secret"
1246
- ]);
1273
+ const MESSAGES = {
1274
+ checkbox: "checkbox",
1275
+ content_alignment: "alignment",
1276
+ text: "text",
1277
+ textarea: "text",
1278
+ richtext: "text"
1279
+ };
1247
1280
  var fallbacks_default = defineRule({
1248
1281
  meta: {
1249
1282
  type: "problem",
1250
- docs: { description: "Disallow ?? fallbacks that never apply, because frontend-core fills unset fields: pickers with their first option, checkboxes with false and text with ''." },
1283
+ docs: { description: "Disallow `??` fallbacks on fields that frontend-core always fills" },
1251
1284
  schema: [],
1252
1285
  messages: {
1253
- picker: "frontend-core fills an unset {{type}} field with its first option, {{fill}}, so this fallback never applies. Make {{fallback}} the first option if it should be the default.",
1254
- checkbox: "frontend-core fills an unset checkbox with false, so this fallback never applies. Name the field so that unchecked is the default.",
1255
- text: "frontend-core fills an unset {{type}} field with '', so ?? never falls back. Use || if an empty field should fall back."
1286
+ picker: "This fallback is never used: an unset {{type}} gets its first option, {{fill}}. To use {{fallback}}, make it the first option.",
1287
+ alignment: "This fallback is never used: an unset content_alignment is {{fill}}. Remove the fallback.",
1288
+ checkbox: "This fallback is never used: an unset checkbox is false. To make true the default, invert the field, e.g. hideIcon instead of showIcon.",
1289
+ text: "An unset {{type}} field is '', so ?? never falls back. Use || instead."
1256
1290
  }
1257
1291
  },
1258
1292
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
@@ -1290,7 +1324,7 @@ var fallbacks_default = defineRule({
1290
1324
  for (const { node, prop } of fallbacks) {
1291
1325
  const field = fills.get(prop);
1292
1326
  if (!field || node.right.type === "Literal" && node.right.value === field.value) continue;
1293
- const messageId = field.type === "checkbox" ? "checkbox" : TEXTS.has(field.type) ? "text" : "picker";
1327
+ const messageId = MESSAGES[field.type] ?? "picker";
1294
1328
  const fill = typeof field.value === "string" ? `'${field.value}'` : String(field.value);
1295
1329
  report({
1296
1330
  node,
@@ -1357,9 +1391,9 @@ const NOT_READ = /* @__PURE__ */ new Set([
1357
1391
  var unused_default = defineRule({
1358
1392
  meta: {
1359
1393
  type: "suggestion",
1360
- docs: { description: "Disallow schema fields the section or block never reads." },
1394
+ docs: { description: "Disallow schema fields the component never uses" },
1361
1395
  schema: [],
1362
- messages: { unused: "Studio shows '{{name}}' to editors, but the component never reads it. Use the field or remove it." }
1396
+ messages: { unused: "The component never uses '{{name}}'. Use the field or remove it." }
1363
1397
  },
1364
1398
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
1365
1399
  create: ({ context, file, report, visitTemplate }) => {
@@ -1412,11 +1446,11 @@ var unused_default = defineRule({
1412
1446
  var casing_default = defineRule({
1413
1447
  meta: {
1414
1448
  type: "problem",
1415
- docs: { description: "Disallow top-level schema field names that Vue renames or rejects as props." },
1449
+ docs: { description: "Disallow schema field names that Vue renames or rejects" },
1416
1450
  schema: [],
1417
1451
  messages: {
1418
- hyphen: "Vue camelizes '{{name}}' to {{suggestion}}, so the prop never arrives under the field's name. Name the field {{suggestion}}.",
1419
- dollar: "Vue rejects prop names that start with $, so '{{name}}' never arrives as a prop. Pick another name."
1452
+ hyphen: "Vue renames '{{name}}' to '{{suggestion}}', so the component never gets this field. Name it '{{suggestion}}'.",
1453
+ dollar: "Vue doesn't allow props that start with $, so the component never gets this field. Rename it."
1420
1454
  }
1421
1455
  },
1422
1456
  applies: (file) => file.side === "app",
@@ -1440,9 +1474,9 @@ var casing_default = defineRule({
1440
1474
  var required_default = defineRule({
1441
1475
  meta: {
1442
1476
  type: "problem",
1443
- docs: { description: "Disallow `required` on schema fields, which Studio doesn't support." },
1477
+ docs: { description: "Disallow `required` on schema fields" },
1444
1478
  schema: [],
1445
- messages: { required: "Schema fields have no required option, so Studio ignores it. Give the field a default instead." }
1479
+ messages: { required: "Studio ignores required. Give the field a default instead." }
1446
1480
  },
1447
1481
  applies: (file) => file.side === "app",
1448
1482
  create: ({ report }) => ({ CallExpression: (node) => {
@@ -1467,11 +1501,11 @@ const RESERVED = /* @__PURE__ */ new Set([
1467
1501
  var reserved_default = defineRule({
1468
1502
  meta: {
1469
1503
  type: "problem",
1470
- docs: { description: "Disallow top-level schema field names that never reach the component's props." },
1504
+ docs: { description: "Disallow schema field names that Vue or frontend-core already use" },
1471
1505
  schema: [],
1472
1506
  messages: {
1473
- reserved: "'{{name}}' never arrives as a prop: Vue handles key, ref, ref_for and ref_key itself, and merges class and style into the root element. Pick another name, e.g. variant for a style selector.",
1474
- slots: "frontend-core passes a section's blocks in its slots prop, so a field named slots is overwritten. Pick another name."
1507
+ reserved: "Vue uses '{{name}}' itself, so the component never gets this field. Rename it.",
1508
+ slots: "frontend-core passes the blocks in the slots prop, so it overwrites this field. Rename it."
1475
1509
  }
1476
1510
  },
1477
1511
  applies: (file) => file.side === "app",
@@ -1506,29 +1540,31 @@ const OWN_ATTRS = /inheritAttrs\s*:\s*false/;
1506
1540
  var root_default = defineRule({
1507
1541
  meta: {
1508
1542
  type: "problem",
1509
- docs: { description: "Require sections and blocks to render one root element, which carries frontend-core's data-lfc-* markers." },
1543
+ docs: { description: "Require sections and blocks to render one root element" },
1510
1544
  schema: [],
1511
1545
  messages: {
1512
- roots: "frontend-core adds its data-lfc-* markers to the root element of a section or block, and Vue drops them when there's more than one root. Wrap the content in one element.",
1513
- componentRoots: "<{{name}}> renders more than one root element, so Vue drops the data-lfc-* markers frontend-core adds to this section or block. Give {{name}} one root, or wrap it here."
1546
+ roots: "Studio can't select a {{kind}} with more than one root element. Wrap the content in one element.",
1547
+ componentRoots: "<{{name}}> has more than one root element, so Studio can't select this {{kind}}. Give it one root, or wrap it here."
1514
1548
  }
1515
1549
  },
1516
1550
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
1517
1551
  create: ({ context, report }) => {
1518
- let defined = false;
1552
+ let kind;
1519
1553
  return {
1520
1554
  "CallExpression": (node) => {
1521
- defined ||= readDefinition(node) !== void 0;
1555
+ const definition = readDefinition(node);
1556
+ kind ??= definition && DEFINERS[definition.definer].kind;
1522
1557
  },
1523
1558
  "Program:exit": () => {
1524
1559
  const program = context.sourceCode.ast;
1525
1560
  const template = program.templateBody;
1526
- if (!defined || !template || OWN_ATTRS.test(context.sourceCode.text)) return;
1561
+ if (!kind || !template || OWN_ATTRS.test(context.sourceCode.text)) return;
1527
1562
  const [root, second] = renderedRoots(template);
1528
1563
  if (second) {
1529
1564
  report({
1530
1565
  loc: second.startTag.loc,
1531
- messageId: "roots"
1566
+ messageId: "roots",
1567
+ data: { kind }
1532
1568
  });
1533
1569
  return;
1534
1570
  }
@@ -1538,7 +1574,10 @@ var root_default = defineRule({
1538
1574
  if (root && componentTemplate && renderedRoots(componentTemplate).length > 1 && !OWN_ATTRS.test(component.text)) report({
1539
1575
  loc: root.startTag.loc,
1540
1576
  messageId: "componentRoots",
1541
- data: { name: root.rawName }
1577
+ data: {
1578
+ name: root.rawName,
1579
+ kind
1580
+ }
1542
1581
  });
1543
1582
  }
1544
1583
  };
@@ -1547,9 +1586,9 @@ var root_default = defineRule({
1547
1586
  var slots_default = defineRule({
1548
1587
  meta: {
1549
1588
  type: "problem",
1550
- docs: { description: "Disallow counting slot children, since frontend-core passes all blocks of a slot as one Fragment." },
1589
+ docs: { description: "Disallow counting the blocks of a slot with `.length`" },
1551
1590
  schema: [],
1552
- messages: { length: "frontend-core passes all blocks of a slot as one Fragment, so this length is 1. Count the rendered blocks with a helper that flattens Fragments." }
1591
+ messages: { length: "For blocks, this is always 1: frontend-core passes them as one Fragment. Count the Fragment's children instead." }
1553
1592
  },
1554
1593
  applies: (file) => file.side === "app",
1555
1594
  create: ({ report, visitTemplate }) => {
@@ -1578,7 +1617,7 @@ var slots_default = defineRule({
1578
1617
  var bundle_default = defineRule({
1579
1618
  meta: {
1580
1619
  type: "problem",
1581
- docs: { description: "Disallow static imports of heavy packages in sections, blocks and plugins, which every page loads." },
1620
+ docs: { description: "Disallow static imports of heavy packages in sections, blocks and plugins" },
1582
1621
  schema: [{
1583
1622
  type: "object",
1584
1623
  properties: { packages: {
@@ -1587,7 +1626,7 @@ var bundle_default = defineRule({
1587
1626
  } },
1588
1627
  additionalProperties: false
1589
1628
  }],
1590
- messages: { heavy: "frontend-core imports every section and block up front, and plugins run on every page, so '{{name}}' lands in the chunk every page loads. Load it with import() or defineAsyncComponent() instead." }
1629
+ messages: { heavy: "This loads '{{name}}' on every page. Load it with import() or defineAsyncComponent() instead." }
1591
1630
  },
1592
1631
  applies: (file) => file.kind === "section" || file.kind === "block" || file.kind === "app-plugin",
1593
1632
  create: ({ context, report }) => {
@@ -1632,9 +1671,9 @@ function setupOptions(node) {
1632
1671
  var keys_default = defineRule({
1633
1672
  meta: {
1634
1673
  type: "problem",
1635
- docs: { description: "Require runtime config keys of apps to be the package itself or one of its dependencies." },
1674
+ docs: { description: "Require runtime config keys to be this package or one of its dependencies" },
1636
1675
  schema: [],
1637
- messages: { key: "'{{key}}' isn't this package or one of its dependencies, so its config is missing at runtime. Use the key of an installed app." }
1676
+ messages: { key: "'{{key}}' isn't this package or one of its dependencies, so this config is undefined." }
1638
1677
  },
1639
1678
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
1640
1679
  create: ({ file, report }) => {
@@ -1659,9 +1698,9 @@ var keys_default = defineRule({
1659
1698
  var public_default = defineRule({
1660
1699
  meta: {
1661
1700
  type: "problem",
1662
- docs: { description: "Disallow copying module options into the public runtime config, which reaches the browser." },
1701
+ docs: { description: "Disallow copying all module options into the public runtime config" },
1663
1702
  schema: [],
1664
- messages: { options: "runtimeConfig.public reaches the browser, and with it every token or secret among the module options. Copy only the options meant to be public, e.g. options.storefrontUrl." }
1703
+ messages: { options: "This sends all module options to the browser, secrets included. Copy only the public ones, e.g. options.storefrontUrl." }
1665
1704
  },
1666
1705
  applies: (file) => file.side === "build",
1667
1706
  create: ({ report }) => ({ AssignmentExpression: (node) => {
@@ -1679,82 +1718,37 @@ var public_default = defineRule({
1679
1718
  });
1680
1719
  } })
1681
1720
  });
1682
- const LABELS = {
1683
- "section": "section",
1684
- "block": "block",
1685
- "component": "component",
1686
- "composable": "composable",
1687
- "app-plugin": "plugin",
1688
- "override": "override",
1689
- "route": "API route",
1690
- "nitro-plugin": "Nitro plugin",
1691
- "orchestr-plugin": "orchestr plugin",
1692
- "orchestr-file": "orchestr",
1693
- "media-library": "media library"
1694
- };
1695
- const ABOVE_APP_UTILS = /* @__PURE__ */ new Set([
1696
- "section",
1697
- "block",
1698
- "component",
1699
- "composable",
1700
- "app-plugin",
1701
- "override"
1702
- ]);
1703
- const ABOVE_COMPOSABLES = /* @__PURE__ */ new Set([
1704
- "section",
1705
- "block",
1706
- "component",
1707
- "override"
1708
- ]);
1709
- const ABOVE_SERVER_UTILS = /* @__PURE__ */ new Set([
1710
- "route",
1711
- "nitro-plugin",
1712
- "orchestr-plugin",
1713
- "orchestr-file",
1714
- "media-library"
1715
- ]);
1716
1721
  function findViolation(importer, target, typeOnly) {
1722
+ if (typeOnly) return;
1717
1723
  if (importer.side === "app" && target.side === "server") return { messageId: "appImportsServer" };
1718
1724
  if (importer.side === "server" && target.side === "app") return { messageId: "serverImportsApp" };
1719
1725
  if (importer.side === "shared" && (target.side === "app" || target.side === "server")) return {
1720
1726
  messageId: "sharedImportsSide",
1721
1727
  data: { side: target.side }
1722
1728
  };
1723
- if (target.side === "build" && !typeOnly) return { messageId: "runtimeImportsBuild" };
1729
+ if (target.side === "build") return { messageId: "runtimeImportsBuild" };
1724
1730
  if (target.kind === "handler") return { messageId: "handlerImported" };
1725
- if (typeOnly) return;
1726
1731
  if (target.kind === "client" && importer.kind === "handler") return { messageId: "clientInHandler" };
1727
- if (importer.kind === "server-util" && ABOVE_SERVER_UTILS.has(target.kind)) return {
1728
- messageId: "serverUtilImportsUp",
1729
- data: { kind: LABELS[target.kind] }
1730
- };
1731
- if (importer.kind === "app-util" && ABOVE_APP_UTILS.has(target.kind)) return {
1732
- messageId: "appUtilImportsUp",
1733
- data: { kind: LABELS[target.kind] }
1734
- };
1735
- if (importer.kind === "composable" && ABOVE_COMPOSABLES.has(target.kind)) return {
1736
- messageId: "composableImportsUp",
1737
- data: { kind: LABELS[target.kind] }
1732
+ if (importer.kind === "component" && (target.kind === "section" || target.kind === "block")) return {
1733
+ messageId: "schemaInComponent",
1734
+ data: { kind: target.kind }
1738
1735
  };
1739
- if (importer.kind === "component" && target.kind === "section") return { messageId: "componentImportsSection" };
1740
1736
  }
1737
+ const SERVER_ONLY = /\.server\.[^/]+$/;
1741
1738
  var layers_default = defineRule({
1742
1739
  meta: {
1743
1740
  type: "problem",
1744
- docs: { description: "Enforce the layers of a Laioutr app: app, server and shared code, build-time code, orchestr handlers, API clients and utils." },
1741
+ docs: { description: "Enforce which parts of a Laioutr app can import each other" },
1745
1742
  schema: [],
1746
1743
  messages: {
1747
- appImportsServer: "App code can't import server code. Move what both sides need to src/runtime/shared/.",
1748
- serverImportsApp: "Server code can't import app code. Move what both sides need to src/runtime/shared/.",
1749
- sharedImportsSide: "Shared code runs in the app and on the server, so it can't import from {{side}}/.",
1750
- runtimeImportsBuild: "Runtime code can't import build-time code from src/; it isn't part of the runtime bundle. Type imports are fine.",
1751
- handlerImported: "Orchestr handlers are registered by laioutr, never imported. Move the shared code to server/utils/.",
1752
- clientInHandler: "Handlers read API clients from the orchestr context. server/client/ only holds clients; move constants and helpers to server/utils/.",
1753
- serverUtilImportsUp: "Server utils are the bottom layer and can't import {{kind}} code.",
1754
- appUtilImportsUp: "App utils are the bottom layer and can't import a {{kind}}. Type imports are fine.",
1755
- composableImportsUp: "Composables can't import a {{kind}}. Type imports are fine.",
1756
- componentImportsSection: "Components can't import sections; sections compose components.",
1757
- nodeBuiltin: "'{{name}}' only exists on the server. Keep it in server/."
1744
+ appImportsServer: "App code can't import server code. Move what both need to src/runtime/shared/.",
1745
+ serverImportsApp: "Server code can't import app code. Move what both need to src/runtime/shared/.",
1746
+ sharedImportsSide: "Shared code can't import from {{side}}/.",
1747
+ runtimeImportsBuild: "Runtime code can't import build code from src/, like module.ts. Type imports are fine.",
1748
+ handlerImported: "Don't import orchestr handlers. Move the code you need to server/orchestr-helper/.",
1749
+ clientInHandler: "Handlers get API clients from the orchestr context, not by import. Move constants to server/const/ and helpers to server/orchestr-helper/.",
1750
+ schemaInComponent: "Components can't import {{kind}}s.",
1751
+ nodeBuiltin: "'{{name}}' only works on the server. Move this code to server/."
1758
1752
  }
1759
1753
  },
1760
1754
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
@@ -1762,7 +1756,7 @@ var layers_default = defineRule({
1762
1756
  const checkImport = (node, source, typeOnly) => {
1763
1757
  if (typeof source !== "string") return;
1764
1758
  if (source.startsWith("node:")) {
1765
- if (importer.side !== "server" && !typeOnly) report({
1759
+ if (importer.side !== "server" && !typeOnly && !SERVER_ONLY.test(importer.path)) report({
1766
1760
  node,
1767
1761
  messageId: "nodeBuiltin",
1768
1762
  data: { name: source }
@@ -1803,10 +1797,9 @@ const plugin = {
1803
1797
  "heavy-imports": bundle_default,
1804
1798
  "config-keys": keys_default,
1805
1799
  "public-config": public_default,
1806
- "orchestr-files": files_default$1,
1800
+ "orchestr-files": files_default,
1807
1801
  "orchestr-cookies": cookies_default,
1808
1802
  "handler-exports": exports_default,
1809
- "middleware-files": files_default,
1810
1803
  "initware-throws": throws_default,
1811
1804
  "token-namespaces": namespaces_default,
1812
1805
  "definition-folder": folder_default,
package/dist/index.d.mts CHANGED
@@ -34,7 +34,6 @@ export declare const plugin: {
34
34
  'orchestr-files': import("eslint").Rule.RuleModule;
35
35
  'orchestr-cookies': import("eslint").Rule.RuleModule;
36
36
  'handler-exports': import("eslint").Rule.RuleModule;
37
- 'middleware-files': import("eslint").Rule.RuleModule;
38
37
  'initware-throws': import("eslint").Rule.RuleModule;
39
38
  'token-namespaces': import("eslint").Rule.RuleModule;
40
39
  'definition-folder': import("eslint").Rule.RuleModule;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "larrylint",
3
3
  "type": "module",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
5
5
  "description": "Opinionated rules for Laioutr apps, as an ESLint preset and a CLI.",
6
6
  "author": "Frederik Bußmann <frederik@bussmann.io>",
7
7
  "license": "MIT",