larrylint 0.2.0 → 0.2.1

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
@@ -34,9 +34,9 @@ After that, `eslint .` and your editor report larrylint's rules next to your own
34
34
  | Rule | Description |
35
35
  | --- | --- |
36
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. |
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
41
  Type imports are fine across most layers, since they don't end up in the bundle.
42
42
 
@@ -48,7 +48,7 @@ Type imports are fine across most layers, since they don't end up in the bundle.
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
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. |
51
+ | `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
52
  | `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
53
 
54
54
  ### Sections and blocks
@@ -58,21 +58,21 @@ Type imports are fine across most layers, since they don't end up in the bundle.
58
58
  | `larrylint/definition-folder` | `defineSection()` lives in `app/sections/` and `defineBlock()` in `app/blocks/` (or `section/` and `block/`), and every `.vue` there has one. |
59
59
  | `larrylint/component-name` | The `component` of a definition matches its file name. |
60
60
  | `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. |
61
+ | `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
62
  | `larrylint/slot-children` | Nothing counts slot children with `.length`: frontend-core passes all blocks of a slot as one Fragment. |
63
63
  | `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. |
64
+ | `larrylint/field-name-case` | Top-level schema field names have no `-` and don't start with `$`, which Vue renames or rejects as props. |
65
65
  | `larrylint/required-fields` | Schema fields have no `required`, which Studio ignores. Give them a `default` instead. |
66
66
  | `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 `''`. |
67
+ | `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
68
 
69
69
  ### Frontend
70
70
 
71
71
  | Rule | Description |
72
72
  | --- | --- |
73
73
  | `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. |
74
+ | `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. |
75
+ | `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
76
  | `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
77
  | `larrylint/internal-anchors` | Internal and resolved links use `<NuxtLink>`: a plain `<a>` reloads the page and breaks Studio's navigation sync. |
78
78
 
@@ -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.1";
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, 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",
@@ -206,6 +129,83 @@ function applyBaseline(cwd, results) {
206
129
  improved
207
130
  };
208
131
  }
132
+ const HANDLER_FILE = /\.(query|resolver|link|action|templates?|page-index)(?:\.[cm]?[jt]s)?$/;
133
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
134
+ const APP_FOLDERS = {
135
+ "sections": "section",
136
+ "section": "section",
137
+ "blocks": "block",
138
+ "block": "block",
139
+ "components": "component",
140
+ "composables": "composable",
141
+ "utils": "app-util",
142
+ "plugins": "app-plugin",
143
+ "overrides": "override",
144
+ "theme": "theme",
145
+ "shared-fields": "shared-field"
146
+ };
147
+ const SERVER_FOLDERS = {
148
+ "middleware": "middleware",
149
+ "client": "client",
150
+ "utils": "server-util",
151
+ "api": "route",
152
+ "routes": "route",
153
+ "plugins": "nitro-plugin",
154
+ "media-library": "media-library",
155
+ "media-libraries": "media-library"
156
+ };
157
+ function classify(file) {
158
+ const absolute = normalize(file);
159
+ const test = TEST_FILE.test(absolute);
160
+ const runtimeIndex = absolute.lastIndexOf("/src/runtime/");
161
+ if (runtimeIndex !== -1) {
162
+ const root = absolute.slice(0, runtimeIndex);
163
+ return {
164
+ root,
165
+ path: absolute.slice(root.length + 1),
166
+ test,
167
+ ...classifyRuntime(absolute.slice(runtimeIndex + 13).split("/"))
168
+ };
169
+ }
170
+ const srcIndex = absolute.lastIndexOf("/src/");
171
+ if (srcIndex === -1) return;
172
+ const root = absolute.slice(0, srcIndex);
173
+ const path = absolute.slice(root.length + 1);
174
+ return {
175
+ root,
176
+ path,
177
+ side: path.startsWith("src/types/") ? "other" : "build",
178
+ test
179
+ };
180
+ }
181
+ function classifyRuntime(parts) {
182
+ const [side, folder = "", ...rest] = parts;
183
+ if (parts.length < 2 || side !== "app" && side !== "server" && side !== "shared") return { side: "other" };
184
+ if (side === "shared") return {
185
+ side,
186
+ kind: "shared"
187
+ };
188
+ if (side === "app") return {
189
+ side,
190
+ kind: APP_FOLDERS[folder] ?? "other"
191
+ };
192
+ if (folder === "orchestr") {
193
+ if (rest.length > 1 && rest[0] === "plugins") return {
194
+ side,
195
+ kind: "orchestr-plugin"
196
+ };
197
+ const handler = HANDLER_FILE.exec(parts.at(-1))?.[1]?.replace("templates", "template");
198
+ return {
199
+ side,
200
+ kind: handler ? "handler" : "orchestr-file",
201
+ handler
202
+ };
203
+ }
204
+ return {
205
+ side,
206
+ kind: SERVER_FOLDERS[folder] ?? "other"
207
+ };
208
+ }
209
209
  function defineRule(rule) {
210
210
  return {
211
211
  meta: rule.meta,
@@ -396,11 +396,11 @@ function canReturnNullish(fn, value) {
396
396
  var mutations_default = defineRule({
397
397
  meta: {
398
398
  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." },
399
+ docs: { description: "Require error handling for awaited or dropped `mutateAsync()` calls" },
400
400
  schema: [],
401
401
  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()."
402
+ unhandled: "If this mutation fails, an error replaces the whole section or block. Catch it with try/catch or .catch().",
403
+ dropped: "Nothing catches errors of this mutation. Add .catch(), or await it in a try/catch."
404
404
  }
405
405
  },
406
406
  applies: (file) => file.side === "app",
@@ -607,9 +607,9 @@ function rendersTag(element, tag) {
607
607
  var anchors_default = defineRule({
608
608
  meta: {
609
609
  type: "problem",
610
- docs: { description: "Disallow plain `<a>` tags for internal and resolved links, which reload the page and break Studio's navigation sync." },
610
+ docs: { description: "Disallow plain `<a>` tags for internal links" },
611
611
  schema: [],
612
- messages: { anchor: "A plain <a> reloads the whole page and breaks Studio's navigation sync. Use <NuxtLink> for internal links." }
612
+ messages: { anchor: "A plain <a> reloads the whole page. Use <NuxtLink> for internal links." }
613
613
  },
614
614
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
615
615
  create: ({ report, visitTemplate }) => {
@@ -646,9 +646,9 @@ const ROUTER_METHODS = /* @__PURE__ */ new Set(["push", "replace"]);
646
646
  var paths_default = defineRule({
647
647
  meta: {
648
648
  type: "problem",
649
- docs: { description: "Disallow hand-built paths to pages, which miss the per-language paths and market prefixes Studio sets." },
649
+ docs: { description: "Disallow page paths built by hand" },
650
650
  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." }
651
+ messages: { path: "Hand-built page paths differ per language and market. Use linkResolver instead." }
652
652
  },
653
653
  applies: (file) => file.side === "app",
654
654
  create: ({ report, visitTemplate }) => {
@@ -681,11 +681,11 @@ var paths_default = defineRule({
681
681
  var resolve_default = defineRule({
682
682
  meta: {
683
683
  type: "problem",
684
- docs: { description: "Disallow testing the result of linkResolver.resolve(), which falls back to a '#…' string instead of an empty value." },
684
+ docs: { description: "Disallow checking the result of `linkResolver.resolve()`" },
685
685
  schema: [],
686
686
  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."
687
+ tested: "linkResolver.resolve() never returns an empty value, so this check misses broken links. Use resolveOrThrow() in a try/catch instead.",
688
+ branch: "linkResolver.resolve() never returns an empty value, so the checks on '{{name}}' miss broken links. Use resolveOrThrow() in a try/catch instead."
689
689
  }
690
690
  },
691
691
  applies: (file) => file.side === "app",
@@ -765,9 +765,9 @@ var button_default = defineRule({
765
765
  meta: {
766
766
  type: "problem",
767
767
  fixable: "code",
768
- docs: { description: "Disallow `type` on the ui-kit buttons, which silently render their `button-type` prop instead." },
768
+ docs: { description: "Disallow `type` on ui-kit buttons" },
769
769
  schema: [],
770
- messages: { buttonType: "<{{tag}}> ignores type and always renders its button-type prop, which defaults to \"button\". Use button-type." }
770
+ messages: { buttonType: "<{{tag}}> ignores type. Use button-type instead." }
771
771
  },
772
772
  applies: (file) => file.path.endsWith(".vue"),
773
773
  create: ({ report, visitTemplate }) => {
@@ -861,11 +861,11 @@ const MANAGED = {
861
861
  var cookies_default = defineRule({
862
862
  meta: {
863
863
  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." },
864
+ docs: { description: "Require cookies and headers to be set in `extendRequest()` or action handlers, with frontend-core's managed cookie functions" },
865
865
  schema: [],
866
866
  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."
867
+ streamed: "The headers may already be sent when {{name}}() runs here. Call it in extendRequest() or an action handler instead.",
868
+ managed: "Use {{managed}}() instead, so it also works in the Studio preview."
869
869
  }
870
870
  },
871
871
  applies: (file) => file.side === "server",
@@ -902,9 +902,9 @@ const FILE_START = {
902
902
  var files_default$1 = defineRule({
903
903
  meta: {
904
904
  type: "problem",
905
- docs: { description: "Keep orchestr/ to handler files, since Laioutr loads every file in it as a Nitro plugin." },
905
+ docs: { description: "Disallow files other than handlers in `orchestr/`" },
906
906
  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." }
907
+ messages: { notAHandler: "Only handlers belong in orchestr/. Move this file to server/utils/, or give it a handler suffix like .query.ts or .action.ts." }
908
908
  },
909
909
  applies: (file) => file.kind === "orchestr-file",
910
910
  create: ({ report }) => ({ Program: () => {
@@ -917,9 +917,9 @@ var files_default$1 = defineRule({
917
917
  var exports_default = defineRule({
918
918
  meta: {
919
919
  type: "problem",
920
- docs: { description: "Require handler files to export their handler as default." },
920
+ docs: { description: "Require handler files to export their handler as default" },
921
921
  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." }
922
+ messages: { missingDefault: "Export the handler as default, or it will fail the build." }
923
923
  },
924
924
  applies: (file) => file.kind === "handler",
925
925
  create: ({ report }) => {
@@ -950,9 +950,9 @@ const MIDDLEWARE = /* @__PURE__ */ new Set([
950
950
  var files_default = defineRule({
951
951
  meta: {
952
952
  type: "suggestion",
953
- docs: { description: "Keep server/middleware/ to orchestr and Nitro middleware." },
953
+ docs: { description: "Disallow files other than middleware in `server/middleware/`" },
954
954
  schema: [],
955
- messages: { helper: "server/middleware/ holds orchestr and Nitro middleware. Move helpers like this to server/utils/." }
955
+ messages: { helper: "Only middleware belongs in server/middleware/. Move this file to server/utils/." }
956
956
  },
957
957
  applies: (file) => file.kind === "middleware",
958
958
  create: ({ report }) => {
@@ -976,11 +976,11 @@ var files_default = defineRule({
976
976
  var throws_default = defineRule({
977
977
  meta: {
978
978
  type: "problem",
979
- docs: { description: "Disallow throwing in extendRequest(), which runs before every query, so a throw takes down every page." },
979
+ docs: { description: "Disallow throwing in `extendRequest()`" },
980
980
  schema: [],
981
981
  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."
982
+ throws: "A throw in extendRequest() breaks every page. Throw later instead, e.g. when the client is used.",
983
+ throwsInCall: "{{name}}() can throw ({{location}}), and a throw in extendRequest() breaks every page. Catch it here."
984
984
  }
985
985
  },
986
986
  applies: (file) => file.side === "server",
@@ -1034,9 +1034,9 @@ function canonicalNamespaces(root) {
1034
1034
  var namespaces_default = defineRule({
1035
1035
  meta: {
1036
1036
  type: "problem",
1037
- docs: { description: "Keep your own token ids out of the namespaces of Laioutr's canonical types." },
1037
+ docs: { description: "Disallow your own token ids in the namespaces of Laioutr's canonical types" },
1038
1038
  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." }
1039
+ messages: { canonical: "'{{namespace}}/' belongs to Laioutr's canonical types. Use your app's own namespace." }
1040
1040
  },
1041
1041
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
1042
1042
  create: ({ file, report }) => ({ CallExpression: (node) => {
@@ -1079,7 +1079,7 @@ const PICKERS = /* @__PURE__ */ new Set([
1079
1079
  "radio",
1080
1080
  "toggle_button"
1081
1081
  ]);
1082
- const TEXTS$1 = /* @__PURE__ */ new Set([
1082
+ const TEXTS = /* @__PURE__ */ new Set([
1083
1083
  "text",
1084
1084
  "textarea",
1085
1085
  "secret"
@@ -1138,7 +1138,7 @@ function expectedDefiner(kind) {
1138
1138
  }
1139
1139
  function fillValue(field, program, filename) {
1140
1140
  if (field.type === "checkbox") return { value: field.as === "visibility" };
1141
- if (TEXTS$1.has(field.type ?? "")) return { value: "" };
1141
+ if (TEXTS.has(field.type ?? "")) return { value: "" };
1142
1142
  if (field.type === "content_alignment") {
1143
1143
  const axis = findStringProperty(field.node, "axis")?.value;
1144
1144
  return { value: !axis || axis === "both" ? "center-center" : "center" };
@@ -1155,31 +1155,32 @@ function fillValue(field, program, filename) {
1155
1155
  var description_default = defineRule({
1156
1156
  meta: {
1157
1157
  type: "suggestion",
1158
- docs: { description: "Require a studio.description on section and block definitions." },
1158
+ docs: { description: "Require a `studio.description` on section and block definitions" },
1159
1159
  schema: [],
1160
- messages: { description: "Studio shows studio.description in its section picker, and AI agents read it through Laioutr's MCP server. Add one." }
1160
+ messages: { description: "Add a studio.description, so editors and AI agents know what this {{kind}} is for." }
1161
1161
  },
1162
1162
  applies: (file) => file.side === "app",
1163
1163
  create: ({ report }) => ({ CallExpression: (node) => {
1164
- const options = readDefinition(node)?.options;
1165
- if (!options) return;
1166
- const studio = findProperty(options, "studio");
1164
+ const definition = readDefinition(node);
1165
+ if (!definition?.options) return;
1166
+ const studio = findProperty(definition.options, "studio");
1167
1167
  if (studio && studio.type !== "ObjectExpression") return;
1168
1168
  const description = studio && findProperty(studio, "description");
1169
1169
  if (!description || description.type === "Literal" && String(description.value ?? "").trim() === "") report({
1170
1170
  node: node.callee,
1171
- messageId: "description"
1171
+ messageId: "description",
1172
+ data: { kind: DEFINERS[definition.definer].kind }
1172
1173
  });
1173
1174
  } })
1174
1175
  });
1175
1176
  var folder_default = defineRule({
1176
1177
  meta: {
1177
1178
  type: "problem",
1178
- docs: { description: "Keep defineSection() in app/sections/ and defineBlock() in app/blocks/, and every .vue there defined." },
1179
+ docs: { description: "Require sections in `app/sections/` and blocks in `app/blocks/`, and a definition in every `.vue` there" },
1179
1180
  schema: [],
1180
1181
  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}}/."
1182
+ notDefined: "Every .vue file in this folder needs {{definer}}(). Add it, or move the file to components/.",
1183
+ wrongFolder: "{{definer}}() belongs in app/{{folder}}/. Move the file there."
1183
1184
  }
1184
1185
  },
1185
1186
  applies: (file) => file.side === "app",
@@ -1202,18 +1203,11 @@ var folder_default = defineRule({
1202
1203
  },
1203
1204
  "Program:exit": () => {
1204
1205
  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
- }
1206
+ if (expected && file.path.endsWith(".vue") && !definers.has(expected)) report({
1207
+ loc: FILE_START,
1208
+ messageId: "notDefined",
1209
+ data: { definer: expected }
1210
+ });
1217
1211
  }
1218
1212
  };
1219
1213
  }
@@ -1221,7 +1215,7 @@ var folder_default = defineRule({
1221
1215
  var name_default = defineRule({
1222
1216
  meta: {
1223
1217
  type: "problem",
1224
- docs: { description: "Require the `component` of a section or block definition to match its file name." },
1218
+ docs: { description: "Require the `component` of a definition to match its file name" },
1225
1219
  schema: [],
1226
1220
  messages: { componentName: "component: '{{actual}}' must match the file name '{{expected}}'." }
1227
1221
  },
@@ -1239,20 +1233,23 @@ var name_default = defineRule({
1239
1233
  });
1240
1234
  } })
1241
1235
  });
1242
- const TEXTS = /* @__PURE__ */ new Set([
1243
- "text",
1244
- "textarea",
1245
- "secret"
1246
- ]);
1236
+ const MESSAGES = {
1237
+ checkbox: "checkbox",
1238
+ content_alignment: "alignment",
1239
+ text: "text",
1240
+ textarea: "text",
1241
+ secret: "text"
1242
+ };
1247
1243
  var fallbacks_default = defineRule({
1248
1244
  meta: {
1249
1245
  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 ''." },
1246
+ docs: { description: "Disallow `??` fallbacks on fields that frontend-core always fills" },
1251
1247
  schema: [],
1252
1248
  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."
1249
+ picker: "This fallback is never used: an unset {{type}} gets its first option, {{fill}}. To use {{fallback}}, make it the first option.",
1250
+ alignment: "This fallback is never used: an unset content_alignment is {{fill}}. Remove the fallback.",
1251
+ 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.",
1252
+ text: "An unset {{type}} field is '', so ?? never falls back. Use || instead."
1256
1253
  }
1257
1254
  },
1258
1255
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
@@ -1290,7 +1287,7 @@ var fallbacks_default = defineRule({
1290
1287
  for (const { node, prop } of fallbacks) {
1291
1288
  const field = fills.get(prop);
1292
1289
  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";
1290
+ const messageId = MESSAGES[field.type] ?? "picker";
1294
1291
  const fill = typeof field.value === "string" ? `'${field.value}'` : String(field.value);
1295
1292
  report({
1296
1293
  node,
@@ -1357,9 +1354,9 @@ const NOT_READ = /* @__PURE__ */ new Set([
1357
1354
  var unused_default = defineRule({
1358
1355
  meta: {
1359
1356
  type: "suggestion",
1360
- docs: { description: "Disallow schema fields the section or block never reads." },
1357
+ docs: { description: "Disallow schema fields the component never uses" },
1361
1358
  schema: [],
1362
- messages: { unused: "Studio shows '{{name}}' to editors, but the component never reads it. Use the field or remove it." }
1359
+ messages: { unused: "The component never uses '{{name}}'. Use the field or remove it." }
1363
1360
  },
1364
1361
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
1365
1362
  create: ({ context, file, report, visitTemplate }) => {
@@ -1412,11 +1409,11 @@ var unused_default = defineRule({
1412
1409
  var casing_default = defineRule({
1413
1410
  meta: {
1414
1411
  type: "problem",
1415
- docs: { description: "Disallow top-level schema field names that Vue renames or rejects as props." },
1412
+ docs: { description: "Disallow schema field names that Vue renames or rejects" },
1416
1413
  schema: [],
1417
1414
  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."
1415
+ hyphen: "Vue renames '{{name}}' to '{{suggestion}}', so the component never gets this field. Name it '{{suggestion}}'.",
1416
+ dollar: "Vue doesn't allow props that start with $, so the component never gets this field. Rename it."
1420
1417
  }
1421
1418
  },
1422
1419
  applies: (file) => file.side === "app",
@@ -1440,9 +1437,9 @@ var casing_default = defineRule({
1440
1437
  var required_default = defineRule({
1441
1438
  meta: {
1442
1439
  type: "problem",
1443
- docs: { description: "Disallow `required` on schema fields, which Studio doesn't support." },
1440
+ docs: { description: "Disallow `required` on schema fields" },
1444
1441
  schema: [],
1445
- messages: { required: "Schema fields have no required option, so Studio ignores it. Give the field a default instead." }
1442
+ messages: { required: "Studio ignores required. Give the field a default instead." }
1446
1443
  },
1447
1444
  applies: (file) => file.side === "app",
1448
1445
  create: ({ report }) => ({ CallExpression: (node) => {
@@ -1467,11 +1464,11 @@ const RESERVED = /* @__PURE__ */ new Set([
1467
1464
  var reserved_default = defineRule({
1468
1465
  meta: {
1469
1466
  type: "problem",
1470
- docs: { description: "Disallow top-level schema field names that never reach the component's props." },
1467
+ docs: { description: "Disallow schema field names that Vue or frontend-core already use" },
1471
1468
  schema: [],
1472
1469
  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."
1470
+ reserved: "Vue uses '{{name}}' itself, so the component never gets this field. Rename it.",
1471
+ slots: "frontend-core passes the blocks in the slots prop, so it overwrites this field. Rename it."
1475
1472
  }
1476
1473
  },
1477
1474
  applies: (file) => file.side === "app",
@@ -1506,29 +1503,31 @@ const OWN_ATTRS = /inheritAttrs\s*:\s*false/;
1506
1503
  var root_default = defineRule({
1507
1504
  meta: {
1508
1505
  type: "problem",
1509
- docs: { description: "Require sections and blocks to render one root element, which carries frontend-core's data-lfc-* markers." },
1506
+ docs: { description: "Require sections and blocks to render one root element" },
1510
1507
  schema: [],
1511
1508
  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."
1509
+ roots: "Studio can't select a {{kind}} with more than one root element. Wrap the content in one element.",
1510
+ componentRoots: "<{{name}}> has more than one root element, so Studio can't select this {{kind}}. Give it one root, or wrap it here."
1514
1511
  }
1515
1512
  },
1516
1513
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
1517
1514
  create: ({ context, report }) => {
1518
- let defined = false;
1515
+ let kind;
1519
1516
  return {
1520
1517
  "CallExpression": (node) => {
1521
- defined ||= readDefinition(node) !== void 0;
1518
+ const definition = readDefinition(node);
1519
+ kind ??= definition && DEFINERS[definition.definer].kind;
1522
1520
  },
1523
1521
  "Program:exit": () => {
1524
1522
  const program = context.sourceCode.ast;
1525
1523
  const template = program.templateBody;
1526
- if (!defined || !template || OWN_ATTRS.test(context.sourceCode.text)) return;
1524
+ if (!kind || !template || OWN_ATTRS.test(context.sourceCode.text)) return;
1527
1525
  const [root, second] = renderedRoots(template);
1528
1526
  if (second) {
1529
1527
  report({
1530
1528
  loc: second.startTag.loc,
1531
- messageId: "roots"
1529
+ messageId: "roots",
1530
+ data: { kind }
1532
1531
  });
1533
1532
  return;
1534
1533
  }
@@ -1538,7 +1537,10 @@ var root_default = defineRule({
1538
1537
  if (root && componentTemplate && renderedRoots(componentTemplate).length > 1 && !OWN_ATTRS.test(component.text)) report({
1539
1538
  loc: root.startTag.loc,
1540
1539
  messageId: "componentRoots",
1541
- data: { name: root.rawName }
1540
+ data: {
1541
+ name: root.rawName,
1542
+ kind
1543
+ }
1542
1544
  });
1543
1545
  }
1544
1546
  };
@@ -1547,9 +1549,9 @@ var root_default = defineRule({
1547
1549
  var slots_default = defineRule({
1548
1550
  meta: {
1549
1551
  type: "problem",
1550
- docs: { description: "Disallow counting slot children, since frontend-core passes all blocks of a slot as one Fragment." },
1552
+ docs: { description: "Disallow counting the blocks of a slot with `.length`" },
1551
1553
  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." }
1554
+ messages: { length: "For blocks, this is always 1: frontend-core passes them as one Fragment. Count the Fragment's children instead." }
1553
1555
  },
1554
1556
  applies: (file) => file.side === "app",
1555
1557
  create: ({ report, visitTemplate }) => {
@@ -1578,7 +1580,7 @@ var slots_default = defineRule({
1578
1580
  var bundle_default = defineRule({
1579
1581
  meta: {
1580
1582
  type: "problem",
1581
- docs: { description: "Disallow static imports of heavy packages in sections, blocks and plugins, which every page loads." },
1583
+ docs: { description: "Disallow static imports of heavy packages in sections, blocks and plugins" },
1582
1584
  schema: [{
1583
1585
  type: "object",
1584
1586
  properties: { packages: {
@@ -1587,7 +1589,7 @@ var bundle_default = defineRule({
1587
1589
  } },
1588
1590
  additionalProperties: false
1589
1591
  }],
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." }
1592
+ messages: { heavy: "This loads '{{name}}' on every page. Load it with import() or defineAsyncComponent() instead." }
1591
1593
  },
1592
1594
  applies: (file) => file.kind === "section" || file.kind === "block" || file.kind === "app-plugin",
1593
1595
  create: ({ context, report }) => {
@@ -1632,9 +1634,9 @@ function setupOptions(node) {
1632
1634
  var keys_default = defineRule({
1633
1635
  meta: {
1634
1636
  type: "problem",
1635
- docs: { description: "Require runtime config keys of apps to be the package itself or one of its dependencies." },
1637
+ docs: { description: "Require runtime config keys to be this package or one of its dependencies" },
1636
1638
  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." }
1639
+ messages: { key: "'{{key}}' isn't this package or one of its dependencies, so this config is undefined." }
1638
1640
  },
1639
1641
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
1640
1642
  create: ({ file, report }) => {
@@ -1659,9 +1661,9 @@ var keys_default = defineRule({
1659
1661
  var public_default = defineRule({
1660
1662
  meta: {
1661
1663
  type: "problem",
1662
- docs: { description: "Disallow copying module options into the public runtime config, which reaches the browser." },
1664
+ docs: { description: "Disallow copying all module options into the public runtime config" },
1663
1665
  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." }
1666
+ messages: { options: "This sends all module options to the browser, secrets included. Copy only the public ones, e.g. options.storefrontUrl." }
1665
1667
  },
1666
1668
  applies: (file) => file.side === "build",
1667
1669
  create: ({ report }) => ({ AssignmentExpression: (node) => {
@@ -1680,17 +1682,17 @@ var public_default = defineRule({
1680
1682
  } })
1681
1683
  });
1682
1684
  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"
1685
+ "section": "sections",
1686
+ "block": "blocks",
1687
+ "component": "components",
1688
+ "composable": "composables",
1689
+ "app-plugin": "plugins",
1690
+ "override": "overrides",
1691
+ "route": "API routes",
1692
+ "nitro-plugin": "Nitro plugins",
1693
+ "orchestr-plugin": "orchestr plugins",
1694
+ "orchestr-file": "orchestr files",
1695
+ "media-library": "media libraries"
1694
1696
  };
1695
1697
  const ABOVE_APP_UTILS = /* @__PURE__ */ new Set([
1696
1698
  "section",
@@ -1741,20 +1743,20 @@ function findViolation(importer, target, typeOnly) {
1741
1743
  var layers_default = defineRule({
1742
1744
  meta: {
1743
1745
  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." },
1746
+ docs: { description: "Enforce which parts of a Laioutr app can import each other" },
1745
1747
  schema: [],
1746
1748
  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/."
1749
+ appImportsServer: "App code can't import server code. Move what both need to src/runtime/shared/.",
1750
+ serverImportsApp: "Server code can't import app code. Move what both need to src/runtime/shared/.",
1751
+ sharedImportsSide: "Shared code can't import from {{side}}/.",
1752
+ runtimeImportsBuild: "Runtime code can't import build code from src/, like module.ts. Type imports are fine.",
1753
+ handlerImported: "Don't import orchestr handlers. Move the code you need to server/utils/.",
1754
+ clientInHandler: "Handlers get API clients from the orchestr context, not by import. Move other code to server/utils/.",
1755
+ serverUtilImportsUp: "Server utils can't import {{kind}}. Type imports are fine.",
1756
+ appUtilImportsUp: "App utils can't import {{kind}}. Type imports are fine.",
1757
+ composableImportsUp: "Composables can't import {{kind}}. Type imports are fine.",
1758
+ componentImportsSection: "Components can't import sections.",
1759
+ nodeBuiltin: "'{{name}}' only works on the server. Move this code to server/."
1758
1760
  }
1759
1761
  },
1760
1762
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
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.1",
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",