@lewishowles/lint-config 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0: 2026-10-05
4
+
5
+ ### Changes
6
+
7
+ - Breaking: `vite-plus` 1.0.0 is now required. Put the selected lint layers in a `vite.config.js` `lint` block; `vp check` and `vp lint` ignore a `.oxlintrc.json` on its own. Use `lintConfig` from `@lewishowles/lint-config/layers` to build the block, with an optional `.oxlintrc.json` for local settings. Vite+ 1.0 also adds its own `vite-plus/prefer-vite-plus-imports` rule, which may report new problems after upgrading.
8
+ - New `./layers` export provides `base`, `vue`, `comments`, and `lintConfig`. The Vue layer includes base, and `lintConfig` carries inherited environments and globals into the Vite+ lint block.
9
+ - `comments/function-documentation` adds an `ignoreInlineArrows` option, enabled by `comments.json` for test files. Arrow functions used as object property values no longer need JSDoc there; other function forms and files keep their existing requirements.
10
+ - `comments/formatting` changes the output of `--fix` for comments. Run `--fix` once after upgrading.
11
+
12
+ ### Fixes
13
+
14
+ - `comments/formatting` keeps code spans, quotes, colons, Markdown blocks, and descriptions in mixed-tag JSDoc blocks as written. It places free-form tag text at the comment margin, removes a separator hyphen when a tag description moves onto its own line, keeps wrapped parameter, return, and throw descriptions indented, and preserves line breaks between adjacent `//` comments.
15
+ - The documentation rules for functions, variables, configured API calls and classes recognise comments across tool directives such as `eslint-disable-next-line`. `comments/function-documentation` accepts plain or optional JSDoc names for destructured parameters with defaults.
16
+
3
17
  ## 0.7.0: 2026-09-30
4
18
 
5
19
  ### Changes
package/README.md CHANGED
@@ -62,6 +62,14 @@ Add the comments layer alongside the base or Vue layer to enforce the comment-fo
62
62
  }
63
63
  ```
64
64
 
65
+ For functions, variables, configured API calls and classes, a tool directive comment such as `// eslint-disable-next-line` may sit between the documentation comment and the code.
66
+
67
+ For destructured parameters with defaults, document the parent and each property. Plain names such as `result.errors` and optional names such as `[result.errors]` or `[result.errors=[]]` all match `errors = []`. The parent can likewise be `result`, `[result]` or `[result={}]`. A documented default does not have to match the value in the code.
68
+
69
+ Lines below a free-form tag such as `@description` or `@note` start at the comment margin so they can be pasted into Markdown without becoming a code block. `@example` content is left as written. Wrapped `@param`, `@returns` and `@throws` descriptions keep a four-space hanging indent so each description is easy to scan beneath its tag. A description written as `name - description` loses the hyphen when it moves onto its own line.
70
+
71
+ Consecutive `//` comments keep their line breaks, and only a line past 80 columns is wrapped.
72
+
65
73
  The Vue component rule reads the raw `.vue` file because Oxlint's JS Plugin API only receives the extracted script block. The comments layer loads its plugin for you, so there's no relative `jsPlugins` path to add. To pick rules yourself instead, add the plugin directly:
66
74
 
67
75
  ```json
@@ -102,36 +110,32 @@ The `comments/variable-declarations` rule accepts a `rootOnly` option to require
102
110
  }
103
111
  ```
104
112
 
113
+ The `comments/function-documentation` rule accepts an `ignoreInlineArrows` option. The comments layer enables it for the same test files, so arrow functions used as object property values do not need JSDoc there. Function declarations, method shorthand, function expression properties, and `const` functions still need JSDoc. Other files keep the default of `false`.
114
+
105
115
  The `comments/class-documentation` rule requires an immediately preceding block comment before class declarations and const-assigned class expressions. Constructors, methods, getters, and setters require full JSDoc; constructors never need an `@returns` tag, and getters and setters need one only when they return a value. Instance and static fields require an immediately preceding line comment.
106
116
 
107
117
  ### `vp check` configuration
108
118
 
109
- The `vp check` and `vp lint` commands read Oxlint settings only from a `lint` block in `vite.config.js`; they do not read `.oxlintrc.json` directly. If `vite.config.js` is missing, they silently use an unrelated default configuration. You may still see plausible warnings and exit codes, but none of your rules are applied.
119
+ On vite-plus 1.0.0, `vp check` and `vp lint` take their Oxlint settings from the `lint` block in `vite.config.js`. They ignore a `.oxlintrc.json` on its own, even though the Vite+ documentation says `vp lint` finds Oxlint config files by itself. If `vite.config.js` is missing, both commands quietly fall back to default settings. You still see plausible warnings and exit codes, but none of your rules are applied.
110
120
 
111
- Each consuming repo needs a `vite.config.js` with a `lint` block built from the config layer(s) and the repo's `.oxlintrc.json`, imported as JSON. Concatenate the `overrides` arrays from every imported layer before the local overrides so shared file-specific rules still apply. Verify the setup with `vp lint --print-config <file>` and check that your real values, not defaults, are active.
121
+ Each consuming repo needs a `vite.config.js` with a `lint` block. Use `lintConfig` with the layers you want. Oxlint ignores `env` and `globals` in configs listed under `extends`, so `lintConfig` copies them into the `lint` block itself, including values from the layers each one extends. Without that, `no-undef` reports Vue macros such as `defineProps` and browser globals such as `window` (see [docs/limitations.md](docs/limitations.md)). The Vue layer already includes base; add comments only if you want the comment rules. You can pass your project's `.oxlintrc.json` as the second argument to keep its local settings. `lintConfig` ignores the `extends` list in that file, so list every layer you want in the first argument.
112
122
 
113
123
  ```js
114
124
  import { defineConfig } from "vite-plus";
115
- import lintConfigBase from "@lewishowles/lint-config/base.json" with { type: "json" };
116
- import lintConfigComments from "@lewishowles/lint-config/comments.json" with { type: "json" };
125
+ import { base, comments, lintConfig } from "@lewishowles/lint-config/layers";
117
126
  import oxlintrc from "./.oxlintrc.json" with { type: "json" };
118
127
 
119
- const lint = {
120
- ...lintConfigBase,
121
- env: oxlintrc.env,
122
- ignorePatterns: oxlintrc.ignorePatterns,
123
- jsPlugins: [...lintConfigBase.jsPlugins, ...lintConfigComments.jsPlugins],
124
- overrides: [
125
- ...(lintConfigBase.overrides ?? []),
126
- ...(lintConfigComments.overrides ?? []),
127
- ...(oxlintrc.overrides ?? []),
128
- ],
129
- rules: { ...lintConfigBase.rules, ...lintConfigComments.rules },
130
- };
131
-
132
- export default defineConfig({ lint });
128
+ export default defineConfig({
129
+ lint: lintConfig([base, comments], oxlintrc),
130
+ });
133
131
  ```
134
132
 
133
+ For a Vue project, use `lintConfig([vue, comments], oxlintrc)` and import `vue` instead of `base`. The local config is optional; `lintConfig([vue, comments])` also works. If you combine layers by hand, use object layers in `extends` and lift their `env` and `globals` to the top level yourself. Spreading JSON layers together replaces earlier `jsPlugins`, `overrides`, and `rules` arrays or objects.
134
+
135
+ Verify the setup with `vp lint --print-config <file>` and check that your real values, not defaults, are active. The printed `rules` leave out every rule that comes from a plugin, such as `comments/*` and `@stylistic/*`, even when those rules are running. To check a plugin layer, confirm the plugin is listed in `jsPlugins`, then lint a file that breaks one of its rules.
136
+
137
+ Vite+ also adds its own `vite-plus` lint plugin, so `vp check` can report rules that this package doesn't define. For example, `vite-plus/prefer-vite-plus-imports` reports imports from `oxlint` packages that Vite+ already provides, such as `oxlint/plugins-dev`.
138
+
135
139
  ## Customising
136
140
 
137
141
  Your project's `.oxlintrc.json` can override rules, add ignore patterns, add overrides, or add plugins on top of the shared layer.
@@ -656,13 +656,10 @@ function reportLineCommentGroups(context) {
656
656
  });
657
657
  });
658
658
 
659
- // The group's lines after refilling words from early-wrapped lines.
660
- const refilledLines = refillCommentLines(formattedLines, maximumLineLength);
661
-
662
659
  // The group's text after applying its final line formatting and
663
660
  // placement gap.
664
661
  const formattedText =
665
- refilledLines
662
+ formattedLines
666
663
  .map(({ prefix, text }) => (text === "" ? prefix.trimEnd() : `${prefix}${text}`))
667
664
  .join(getNewline(context.sourceCode.text)) + (placement?.gap ?? "");
668
665
 
@@ -833,9 +830,10 @@ function reportBlockComments(context) {
833
830
  }
834
831
 
835
832
  /**
836
- * The comment-formatting rule: punctuates comments as sentences, refills
837
- * early-wrapped lines, reindents line-comment groups and wraps any comment past
838
- * 80 columns, replacing each comment in one edit.
833
+ * The comment-formatting rule: punctuates comments as sentences and wraps any
834
+ * comment past 80 columns, replacing each comment in one edit. Block comments
835
+ * that wrap too early are refilled. Line-comment groups are reindented, but
836
+ * words never move between their lines.
839
837
  */
840
838
  export default {
841
839
  meta: {
@@ -53,6 +53,18 @@ export default {
53
53
  meta: {
54
54
  docs: { description: "Require JSDoc documentation for named functions and methods." },
55
55
  type: "suggestion",
56
+ schema: [
57
+ {
58
+ type: "object",
59
+ properties: {
60
+ ignoreInlineArrows: {
61
+ type: "boolean",
62
+ },
63
+ },
64
+ additionalProperties: false,
65
+ },
66
+ ],
67
+ defaultOptions: [{ ignoreInlineArrows: false }],
56
68
  },
57
69
  /**
58
70
  * Create the rule's node visitors.
@@ -77,13 +89,20 @@ export default {
77
89
  }
78
90
  },
79
91
  /**
80
- * Check a first-level object method for documentation.
92
+ * Check a first-level object method for documentation. When the
93
+ * ignoreInlineArrows option is on, arrow function values are
94
+ * skipped, which suits small inline callbacks.
81
95
  *
82
96
  * @param {object} node
83
97
  * The property node.
84
98
  */
85
99
  Property(node) {
86
- if (!isFirstLevelObjectProperty(node) || !isFunctionValue(node.value)) {
100
+ if (
101
+ !isFirstLevelObjectProperty(node) ||
102
+ !isFunctionValue(node.value) ||
103
+ (context.options?.[0]?.ignoreInlineArrows &&
104
+ node.value.type === "ArrowFunctionExpression")
105
+ ) {
87
106
  return;
88
107
  }
89
108
 
@@ -2,7 +2,7 @@ import { getJSDocContent, isJSDoc } from "./jsdoc.js";
2
2
  import {
3
3
  getCommentNeighbours,
4
4
  getCommentText,
5
- isDirectiveComment,
5
+ getImmediateNonDirectiveComment,
6
6
  isLeadingComment,
7
7
  } from "./source.js";
8
8
  import { getPropertyName } from "./vue-macro.js";
@@ -45,8 +45,8 @@ export function isFunctionValue(node) {
45
45
  }
46
46
 
47
47
  /**
48
- * Return the JSDoc comment immediately preceding a node, when the comment
49
- * qualifies as that node's documentation.
48
+ * Return the JSDoc comment directly above a node, when the comment qualifies as
49
+ * that node's documentation. Tool directive comments may sit between them.
50
50
  *
51
51
  * @param {object} sourceCode
52
52
  * The Oxlint source code object.
@@ -57,26 +57,21 @@ export function isFunctionValue(node) {
57
57
  * The qualifying JSDoc comment, or null when none precedes the node.
58
58
  */
59
59
  function getDocumentationComment(sourceCode, node) {
60
- // Finds the closest preceding comment.
61
- const comment = sourceCode
62
- .getAllComments()
63
- .findLast((candidate) => candidate.range[1] <= node.range[0]);
60
+ // The closest comment above the node, looking past tool directive comments.
61
+ const comment = getImmediateNonDirectiveComment(sourceCode, node);
64
62
 
65
- if (comment?.type !== "Block" || isDirectiveComment(comment)) {
63
+ if (comment?.type !== "Block") {
66
64
  return null;
67
65
  }
68
66
 
69
67
  // Checks the comments immediately around the documented node.
70
68
  const { next, previous } = getCommentNeighbours(sourceCode, comment);
71
- // Confirms there is no blank line before the documented node.
72
- const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
73
69
 
74
70
  if (
75
71
  !next ||
76
72
  next.range[0] > node.range[0] ||
77
73
  next.range[1] > node.range[1] ||
78
- !isLeadingComment(sourceCode, comment, previous) ||
79
- !/^\r?\n[ \t]*$/.test(gap)
74
+ !isLeadingComment(sourceCode, comment, previous)
80
75
  ) {
81
76
  return null;
82
77
  }
@@ -192,8 +187,9 @@ function getParameterPaths(sourceCode, node, rootPath = "options") {
192
187
  * The JSDoc comment token.
193
188
  *
194
189
  * @returns {object}
195
- * An object with names, the set of every documented parameter path, and
196
- * topLevelNames, the top-level names in the order they are documented.
190
+ * An object with names, the set of every documented parameter path with
191
+ * optional brackets and defaults removed, and topLevelNames, the top-level
192
+ * names in the order they are documented.
197
193
  */
198
194
  function getDocumentedParameters(sourceCode, comment) {
199
195
  // Splits the JSDoc block into its individual lines.
@@ -205,21 +201,16 @@ function getDocumentedParameters(sourceCode, comment) {
205
201
 
206
202
  for (const line of content) {
207
203
  // Matches an @param tag and captures its documented path.
208
- const match = line.trim().match(/^@param(?:\s+\{[^}]+\})?\s+(\[[^\]]+\]|\S+)/);
204
+ const match = line.trim().match(/^@param(?:\s+\{[^}]+\})?\s+(\S+)/);
209
205
 
210
206
  if (match) {
211
- // The documented name as written, including optional brackets and
212
- // any default value.
213
- const name = match[1];
207
+ // The documented path without optional brackets or a default value.
208
+ const name = getPlainParameterPath(match[1]);
214
209
 
215
210
  names.add(name);
216
211
 
217
- // Removes optional and default-value syntax before checking for a
218
- // nested path.
219
- const topLevelName = name.replace(/^\[|\]$/g, "").split("=")[0];
220
-
221
- if (!topLevelName.includes(".")) {
222
- topLevelNames.push(topLevelName);
212
+ if (!name.includes(".")) {
213
+ topLevelNames.push(name);
223
214
  }
224
215
  }
225
216
  }
@@ -334,10 +325,12 @@ export function reportFunctionDocumentation(context, node, functionNode, options
334
325
  );
335
326
 
336
327
  for (const path of parameterPaths) {
337
- // The optional JSDoc spelling for this parameter path.
338
- const optionalPath = `[${path}]`;
328
+ // The path without optional brackets or a default. Documented defaults
329
+ // are not compared with the code, so result.errors, [result.errors] and
330
+ // [result.errors=[]] all document the same property.
331
+ const plainPath = getPlainParameterPath(path);
339
332
 
340
- if (!documentedParameters.has(path) && !documentedParameters.has(optionalPath)) {
333
+ if (!documentedParameters.has(plainPath)) {
341
334
  context.report({
342
335
  message: `${subject} require an @param for ${path}.`,
343
336
  node,
@@ -366,3 +359,18 @@ export function reportFunctionDocumentation(context, node, functionNode, options
366
359
  });
367
360
  }
368
361
  }
362
+
363
+ /**
364
+ * Reduce a parameter path to its plain name, so a documented path matches the
365
+ * required one however the documentation spells it.
366
+ *
367
+ * @param {string} path
368
+ * The documented or required parameter path.
369
+ *
370
+ * @returns {string}
371
+ * The path with brackets and any default removed, such as result.errors for
372
+ * [result.errors=[]].
373
+ */
374
+ function getPlainParameterPath(path) {
375
+ return path.replace(/^\[|\]$/g, "").split("=")[0];
376
+ }
@@ -13,6 +13,10 @@ const tagOrder = ["param", "throws", "returns"];
13
13
  const preservedSectionTags = new Set(["example"]);
14
14
  // Fast lookup set built from tagOrder.
15
15
  const targetTags = new Set(tagOrder);
16
+ // Markdown headings start with one to six hashes and a space.
17
+ const markdownHeadingPattern = /^#{1,6}\s/;
18
+ // Markdown list items start with a bullet or numbered marker and a space.
19
+ const markdownListItemPattern = /^(?:[-*]|\d+[.)])\s/;
16
20
 
17
21
  /**
18
22
  * Return whether source text is a JSDoc-style block comment.
@@ -174,19 +178,19 @@ function parseTargetTags(tagLines) {
174
178
  * The aligned tag header.
175
179
  */
176
180
  function formatTagHeader(entry) {
177
- // Splits the tag's remainder into its type, name, and description.
178
- const typeMatch = entry.rest.match(/^(\{[^}]+\})(?:\s+(\S+))?(?:\s+(.*))?$/);
181
+ // The tag's type annotation, plus a name when the tag is @param.
182
+ const typeMatch = parseTargetTagRest(entry);
179
183
 
180
184
  if (!typeMatch) {
181
185
  return `@${entry.type} ${entry.rest}`.trimEnd();
182
186
  }
183
187
 
184
188
  // The bare {type} annotation.
185
- const type = typeMatch[1];
189
+ const type = typeMatch.groups.type;
186
190
  // The parameter name, when the tag has one.
187
- const name = typeMatch[2];
191
+ const name = typeMatch.groups.name;
188
192
 
189
- if (entry.type === "param" && name) {
193
+ if (name) {
190
194
  return `@param ${type} ${name}`;
191
195
  }
192
196
 
@@ -194,23 +198,204 @@ function formatTagHeader(entry) {
194
198
  }
195
199
 
196
200
  /**
197
- * Return the inline description from a target tag.
201
+ * Return a target tag's inline description without a separator hyphen. Once the
202
+ * description moves onto its own line, a leading hyphen would read as a
203
+ * Markdown bullet.
198
204
  *
199
205
  * @param {object} entry
200
206
  * The parsed tag entry.
201
207
  *
202
208
  * @returns {string}
203
- * The inline description, when present.
209
+ * The inline description, or an empty string when there is none.
204
210
  */
205
211
  function getInlineTagDescription(entry) {
206
- // Splits the tag's remainder into its type, name, and description.
207
- const typeMatch = entry.rest.match(/^(\{[^}]+\})(?:\s+(\S+))?(?:\s+(.*))?$/);
212
+ // The parsed type and description, with a name only for parameters.
213
+ const tagParts = parseTargetTagRest(entry);
208
214
 
209
- return typeMatch?.[3] ?? "";
215
+ return (tagParts?.groups.description ?? "").replace(/^-(?:\s+|$)/, "");
216
+ }
217
+
218
+ /**
219
+ * Parse a target tag's type and inline description, including a parameter name
220
+ * only when the tag is @param.
221
+ *
222
+ * @param {object} entry
223
+ * The parsed tag entry.
224
+ *
225
+ * @returns {RegExpMatchArray|null}
226
+ * The type, name and description, or null when there is no type.
227
+ */
228
+ function parseTargetTagRest(entry) {
229
+ if (entry.type === "param") {
230
+ return entry.rest.match(/^(?<type>\{[^}]+\})(?:\s+(?<name>\S+))?(?:\s+(?<description>.*))?$/);
231
+ }
232
+
233
+ return entry.rest.match(/^(?<type>\{[^}]+\})(?:\s+(?<description>.*))?$/);
234
+ }
235
+
236
+ /**
237
+ * Find the lines that belong to a Markdown heading, list, table or fenced code
238
+ * block, so formatting can leave them exactly as written.
239
+ *
240
+ * @param {string[]} lines
241
+ * The undecorated JSDoc lines.
242
+ *
243
+ * @returns {boolean[]}
244
+ * True for each line that belongs to a Markdown block, in line order.
245
+ */
246
+ function getMarkdownStructure(lines) {
247
+ // Whether each line read so far belongs to a Markdown block.
248
+ const structure = [];
249
+
250
+ // Whether the current line is inside a fenced code block.
251
+ let inFence = false;
252
+ // The indentation of the open list item; deeper lines continue it.
253
+ let listIndent = null;
254
+
255
+ for (const line of lines) {
256
+ // The line without surrounding whitespace.
257
+ const trimmed = line.trim();
258
+ // The number of whitespace characters before the line's text.
259
+ const indent = line.length - line.trimStart().length;
260
+
261
+ // A list or table can start on the first line, after a blank line, or
262
+ // straight after another Markdown block.
263
+ const blockBoundary =
264
+ structure.length === 0 || lines[structure.length - 1].trim() === "" || structure.at(-1);
265
+
266
+ if (inFence) {
267
+ structure.push(true);
268
+
269
+ inFence = !trimmed.startsWith("```");
270
+
271
+ continue;
272
+ }
273
+
274
+ if (trimmed.startsWith("```")) {
275
+ structure.push(true);
276
+
277
+ inFence = true;
278
+ listIndent = null;
279
+
280
+ continue;
281
+ }
282
+
283
+ // A bullet or a list numbered from 1 can follow prose directly. Other
284
+ // numbers need a block boundary, so prose such as "2) the second case"
285
+ // isn't read as a list.
286
+ if (
287
+ markdownListItemPattern.test(trimmed) &&
288
+ (blockBoundary || /^1[.)]\s/.test(trimmed) || /^[-*]\s/.test(trimmed))
289
+ ) {
290
+ structure.push(true);
291
+
292
+ listIndent = indent;
293
+
294
+ continue;
295
+ }
296
+
297
+ // An indented line continues the list item above it.
298
+ if (trimmed !== "" && listIndent !== null && indent > listIndent) {
299
+ structure.push(true);
300
+
301
+ continue;
302
+ }
303
+
304
+ listIndent = null;
305
+
306
+ // A heading can start anywhere, but a table must start at a boundary.
307
+ structure.push(
308
+ markdownHeadingPattern.test(trimmed) || (blockBoundary && trimmed.startsWith("|")),
309
+ );
310
+ }
311
+
312
+ return structure;
313
+ }
314
+
315
+ /**
316
+ * Format the prose in a run of JSDoc lines and copy Markdown block lines
317
+ * through unchanged.
318
+ *
319
+ * @param {string[]} lines
320
+ * The undecorated JSDoc lines.
321
+ * @param {function} formatPlainLines
322
+ * Formats one run of prose lines and returns the formatted lines.
323
+ * @param {string[]} structureLines
324
+ * The lines to check for Markdown blocks, when they differ from the lines
325
+ * being formatted. Tag descriptions pass a blank line in place of the
326
+ * inline description so it is never read as Markdown.
327
+ *
328
+ * @returns {string[]}
329
+ * The formatted prose and unchanged Markdown lines.
330
+ */
331
+ function splitMarkdownProse(lines, formatPlainLines, structureLines = lines) {
332
+ // Whether each line belongs to a Markdown block.
333
+ const structure = getMarkdownStructure(structureLines);
334
+ // The formatted lines, including unchanged Markdown blocks.
335
+ const result = [];
336
+
337
+ // The prose waiting to be formatted.
338
+ let prose = [];
339
+
340
+ /**
341
+ * Format the waiting prose, keeping the blank line that separated it from
342
+ * the next Markdown block.
343
+ *
344
+ * @param {boolean} keepBlank
345
+ * Whether a Markdown block follows, so the blank line before it stays.
346
+ */
347
+ function flushProse(keepBlank = false) {
348
+ if (prose.length > 0) {
349
+ // Whether the source separates the prose from the next block.
350
+ const endsWithBlank = prose.at(-1)?.trim() === "";
351
+
352
+ result.push(...formatPlainLines(prose));
353
+
354
+ if (keepBlank && endsWithBlank && result.at(-1) !== "") {
355
+ result.push("");
356
+ }
357
+
358
+ prose = [];
359
+ }
360
+ }
361
+
362
+ for (let index = 0; index < lines.length; index += 1) {
363
+ if (structure[index]) {
364
+ flushProse(true);
365
+ result.push(lines[index]);
366
+ } else {
367
+ prose.push(lines[index]);
368
+ }
369
+ }
370
+
371
+ flushProse();
372
+
373
+ return result;
374
+ }
375
+
376
+ /**
377
+ * Wrap prose without making a new line look like a Markdown block.
378
+ *
379
+ * @param {string} text
380
+ * The prose to wrap.
381
+ * @param {number} width
382
+ * The available content width.
383
+ *
384
+ * @returns {string[]}
385
+ * The wrapped prose lines.
386
+ */
387
+ function wrapProse(text, width) {
388
+ // Joins each word that looks like a Markdown marker to the word before it
389
+ // with a word joiner (U+2060), so wrapping never starts a line with it. The
390
+ // joiner turns back into a space before the lines are returned.
391
+ const joined = text.replace(/(\S+)\s+([-*|]|\d+[.)]|#{1,6})(?=\s)/g, "$1\u2060$2");
392
+
393
+ return wrapWords(joined, width).map((line) => line.replaceAll("\u2060", " "));
210
394
  }
211
395
 
212
396
  /**
213
397
  * Format and refill JSDoc prose while preserving paragraph and list boundaries.
398
+ * Markdown blocks are left as written.
214
399
  *
215
400
  * @param {string[]} lines
216
401
  * The prose content lines.
@@ -223,6 +408,25 @@ function getInlineTagDescription(entry) {
223
408
  * The formatted prose lines.
224
409
  */
225
410
  function formatUnwrappedProse(lines, addPunctuation, indentation) {
411
+ return splitMarkdownProse(lines, (prose) =>
412
+ formatPlainUnwrappedProse(prose, addPunctuation, indentation),
413
+ );
414
+ }
415
+
416
+ /**
417
+ * Refill and format JSDoc prose that contains no Markdown blocks.
418
+ *
419
+ * @param {string[]} lines
420
+ * The prose lines to refill.
421
+ * @param {boolean} addPunctuation
422
+ * Whether to format each paragraph as a sentence.
423
+ * @param {string} indentation
424
+ * The indentation used by the comment.
425
+ *
426
+ * @returns {string[]}
427
+ * The refilled prose lines.
428
+ */
429
+ function formatPlainUnwrappedProse(lines, addPunctuation, indentation) {
226
430
  // The prose lines after refilling, then formatted in place below.
227
431
  const result = refillCommentLines(
228
432
  lines.map((line) => ({ prefix: `${indentation} * `, text: line })),
@@ -259,7 +463,8 @@ function formatUnwrappedProse(lines, addPunctuation, indentation) {
259
463
  }
260
464
 
261
465
  /**
262
- * Format prose paragraphs to the block-comment width.
466
+ * Format prose paragraphs to the block-comment width, leaving Markdown blocks
467
+ * as written.
263
468
  *
264
469
  * @param {string[]} lines
265
470
  * The prose content lines.
@@ -272,7 +477,26 @@ function formatUnwrappedProse(lines, addPunctuation, indentation) {
272
477
  * Formatted prose content lines.
273
478
  */
274
479
  function formatProse(lines, width, addPunctuation) {
275
- // The formatted lines, built up in place.
480
+ return splitMarkdownProse(lines, (proseLines) =>
481
+ formatPlainProse(proseLines, width, addPunctuation),
482
+ );
483
+ }
484
+
485
+ /**
486
+ * Format prose paragraphs that contain no Markdown blocks.
487
+ *
488
+ * @param {string[]} lines
489
+ * The prose lines.
490
+ * @param {number} width
491
+ * The available content width.
492
+ * @param {boolean} addPunctuation
493
+ * Whether to format each paragraph as a sentence.
494
+ *
495
+ * @returns {string[]}
496
+ * The formatted prose lines.
497
+ */
498
+ function formatPlainProse(lines, width, addPunctuation) {
499
+ // The formatted prose lines, built up in place.
276
500
  const result = [];
277
501
 
278
502
  // The prose lines collected for the paragraph in progress.
@@ -293,12 +517,15 @@ function formatProse(lines, width, addPunctuation) {
293
517
  text = formatSentence(text);
294
518
  }
295
519
 
296
- result.push(...wrapWords(text, width));
520
+ result.push(...wrapProse(text, width));
297
521
 
298
522
  paragraph = [];
299
523
  }
300
524
 
301
- for (const line of lines) {
525
+ for (let index = 0; index < lines.length; index += 1) {
526
+ // The current undecorated JSDoc line.
527
+ const line = lines[index];
528
+
302
529
  if (line.trim() === "") {
303
530
  flushParagraph();
304
531
 
@@ -325,7 +552,8 @@ function formatProse(lines, width, addPunctuation) {
325
552
  * @param {string[]} tagLines
326
553
  * The undecorated tag content.
327
554
  * @param {number} width
328
- * The available description width.
555
+ * The width of the comment text. @param, @returns and @throws descriptions
556
+ * wrap four characters narrower to fit their indent.
329
557
  * @param {boolean} addPunctuation
330
558
  * Whether to format tag descriptions as sentences.
331
559
  * @param {boolean} normaliseTags
@@ -364,22 +592,7 @@ function formatTags(tagLines, width, addPunctuation, normaliseTags) {
364
592
  }
365
593
 
366
594
  result.push(formatTagHeader(entry));
367
-
368
- // The entry's inline and multi-line description text, combined.
369
- const description = [getInlineTagDescription(entry), ...entry.description]
370
- .filter((line) => line.trim() !== "")
371
- .map((line) => line.trim());
372
-
373
- // The description text, punctuated as a sentence when requested.
374
- let descriptionText = description.join(" ");
375
-
376
- if (addPunctuation && descriptionText !== "") {
377
- descriptionText = formatSentence(descriptionText);
378
- }
379
-
380
- if (descriptionText !== "") {
381
- result.push(...wrapWords(descriptionText, width).map((line) => ` ${line}`));
382
- }
595
+ result.push(...formatTargetDescription(entry, width, addPunctuation));
383
596
 
384
597
  lastType = entry.type;
385
598
  }
@@ -393,7 +606,8 @@ function formatTags(tagLines, width, addPunctuation, normaliseTags) {
393
606
  * @param {string[]} lines
394
607
  * The undecorated tag content lines.
395
608
  * @param {number} width
396
- * The available description width.
609
+ * The width of the comment text. @param, @returns and @throws descriptions
610
+ * wrap four characters narrower to fit their indent.
397
611
  * @param {boolean} addPunctuation
398
612
  * Whether to format descriptions as sentences.
399
613
  *
@@ -401,6 +615,9 @@ function formatTags(tagLines, width, addPunctuation, normaliseTags) {
401
615
  * Formatted mixed tag content lines.
402
616
  */
403
617
  function formatMixedTags(lines, width, addPunctuation) {
618
+ // Whether each line belongs to a Markdown block. Tag lines count as blank
619
+ // so a list ends at the next tag.
620
+ const structure = getMarkdownStructure(lines.map((line) => (getJSDocTagName(line) ? "" : line)));
404
621
  // The formatted lines, built up in place.
405
622
  const result = [];
406
623
 
@@ -409,12 +626,28 @@ function formatMixedTags(lines, width, addPunctuation) {
409
626
  // Whether the current tag's content is copied through unchanged.
410
627
  let preserveSection = false;
411
628
 
412
- for (const line of lines) {
629
+ for (let index = 0; index < lines.length; index += 1) {
630
+ // The current undecorated JSDoc line.
631
+ const line = lines[index];
413
632
  // The tag name, or null when the line isn't a tag at all.
414
633
  const tagName = getJSDocTagName(line);
415
634
  // Matches a new @param/@throws/@returns tag line.
416
635
  const match = line.trim().match(/^@(param|throws|returns)\b(.*)$/);
417
636
 
637
+ if (tagName !== null && currentEntry) {
638
+ // Whether the author left a blank line between this description and
639
+ // the next tag.
640
+ const hasTagSeparator = currentEntry.description.at(-1)?.trim() === "";
641
+
642
+ result.push(...formatTargetDescription(currentEntry, width, addPunctuation));
643
+
644
+ if (hasTagSeparator && result.at(-1) !== "") {
645
+ result.push("");
646
+ }
647
+
648
+ currentEntry = null;
649
+ }
650
+
418
651
  if (match) {
419
652
  currentEntry = {
420
653
  description: [],
@@ -426,30 +659,28 @@ function formatMixedTags(lines, width, addPunctuation) {
426
659
 
427
660
  result.push(formatTagHeader(currentEntry));
428
661
  } else if (tagName !== null) {
429
- currentEntry = null;
430
662
  preserveSection = isPreservedSectionTag(line);
431
663
 
432
664
  result.push(line.trim());
665
+ } else if (currentEntry) {
666
+ currentEntry.description.push(line);
433
667
  } else if (preserveSection) {
434
668
  result.push(line);
669
+ } else if (structure[index]) {
670
+ result.push(line);
435
671
  } else if (line.trim() === "") {
436
- currentEntry = null;
437
-
438
672
  if (result.at(-1) !== "") {
439
673
  result.push("");
440
674
  }
441
675
  } else {
442
- // The description text, punctuated as a sentence when requested.
443
- let text = line.trim();
444
-
445
- if (addPunctuation && currentEntry) {
446
- text = formatSentence(text);
447
- }
448
-
449
- result.push(...wrapWords(text, width).map((wrappedLine) => ` ${wrappedLine}`));
676
+ result.push(...wrapProse(line.trim(), width));
450
677
  }
451
678
  }
452
679
 
680
+ if (currentEntry) {
681
+ result.push(...formatTargetDescription(currentEntry, width, addPunctuation));
682
+ }
683
+
453
684
  while (result.at(-1) === "") {
454
685
  result.pop();
455
686
  }
@@ -457,13 +688,60 @@ function formatMixedTags(lines, width, addPunctuation) {
457
688
  return result;
458
689
  }
459
690
 
691
+ /**
692
+ * Format the description of a @param, @returns or @throws tag, joining the text
693
+ * on the tag line with the lines below it.
694
+ *
695
+ * @param {object} entry
696
+ * The parsed tag, with its tag-line text and the description lines
697
+ * collected beneath it.
698
+ * @param {number} width
699
+ * The width of the comment text. The description wraps four characters
700
+ * narrower to fit its indent.
701
+ * @param {boolean} addPunctuation
702
+ * Whether to format prose paragraphs as sentences.
703
+ *
704
+ * @returns {string[]}
705
+ * The formatted description lines beneath the tag header.
706
+ */
707
+ function formatTargetDescription(entry, width, addPunctuation) {
708
+ // The description written on the tag line, without a leading hyphen.
709
+ const inlineDescription = getInlineTagDescription(entry);
710
+ // The full tag description, before wrapping.
711
+ const description = [inlineDescription, ...entry.description];
712
+
713
+ // Drop blank lines around the description.
714
+ while (description[0]?.trim() === "") {
715
+ description.shift();
716
+ }
717
+
718
+ while (description.at(-1)?.trim() === "") {
719
+ description.pop();
720
+ }
721
+
722
+ // Ignore the inline description when finding Markdown blocks, so a
723
+ // description that starts with something like "1." or "#" stays prose.
724
+ const structuralDescription = inlineDescription ? ["", ...description.slice(1)] : description;
725
+
726
+ return splitMarkdownProse(
727
+ description,
728
+ (prose) => {
729
+ return formatPlainProse(prose, Math.max(1, width - 4), addPunctuation).map((line) => {
730
+ return line === "" ? "" : ` ${line}`;
731
+ });
732
+ },
733
+ structuralDescription,
734
+ );
735
+ }
736
+
460
737
  /**
461
738
  * Format descriptions while retaining non-target tag lines.
462
739
  *
463
740
  * @param {string[]} lines
464
741
  * The undecorated tag content lines.
465
742
  * @param {number} width
466
- * The available description width.
743
+ * The width of the comment text. @param, @returns and @throws descriptions
744
+ * wrap four characters narrower to fit their indent.
467
745
  * @param {boolean} addPunctuation
468
746
  * Whether to format descriptions as sentences.
469
747
  *
@@ -471,11 +749,17 @@ function formatMixedTags(lines, width, addPunctuation) {
471
749
  * Formatted tag content lines.
472
750
  */
473
751
  function formatTagDescriptions(lines, width, addPunctuation) {
752
+ // Whether each line belongs to a Markdown block. Tag lines count as blank
753
+ // so a list ends at the next tag.
754
+ const structure = getMarkdownStructure(lines.map((line) => (getJSDocTagName(line) ? "" : line)));
474
755
  // The formatted lines, built up in place.
475
756
  const result = [];
476
757
 
477
758
  // The description lines collected for the tag in progress.
478
759
  let description = [];
760
+ // Whether the current tag is @param, @returns or @throws, whose description
761
+ // keeps a four-space indent.
762
+ let indentDescription = false;
479
763
  // Whether the current tag's content is copied through unchanged.
480
764
  let preserveSection = false;
481
765
 
@@ -494,12 +778,20 @@ function formatTagDescriptions(lines, width, addPunctuation) {
494
778
  text = formatSentence(text);
495
779
  }
496
780
 
497
- result.push(...wrapWords(text, width).map((line) => ` ${line}`));
781
+ // The wrapping width, four characters narrower for @param, @returns and
782
+ // @throws so their indent still fits.
783
+ const descriptionWidth = indentDescription ? Math.max(1, width - 4) : width;
784
+ // The indent applied to each wrapped description line.
785
+ const indent = indentDescription ? " " : "";
786
+
787
+ result.push(...wrapProse(text, descriptionWidth).map((line) => `${indent}${line}`));
498
788
 
499
789
  description = [];
500
790
  }
501
791
 
502
- for (const line of lines) {
792
+ for (let index = 0; index < lines.length; index += 1) {
793
+ // The current undecorated JSDoc line.
794
+ const line = lines[index];
503
795
  // The tag name, or null when the line isn't a tag at all.
504
796
  const tagName = getJSDocTagName(line);
505
797
 
@@ -507,9 +799,13 @@ function formatTagDescriptions(lines, width, addPunctuation) {
507
799
  flushDescription();
508
800
  result.push(line.trim());
509
801
 
802
+ indentDescription = isTargetTag(line);
510
803
  preserveSection = isPreservedSectionTag(line);
511
804
  } else if (preserveSection) {
512
805
  result.push(line);
806
+ } else if (structure[index]) {
807
+ flushDescription();
808
+ result.push(line);
513
809
  } else if (line.trim() === "") {
514
810
  flushDescription();
515
811
 
@@ -645,15 +941,8 @@ export function formatJSDocBlockStructure(commentText, formattingOptions) {
645
941
  const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
646
942
  // The prose lines, refilled before tags are appended.
647
943
  const prose = formatUnwrappedProse(formattingContext.proseLines, false, formattingContext.indent);
648
-
649
944
  // The tag lines, without spacing or grouping normalisation.
650
- const tags = formatTags(
651
- formattingContext.tagLines,
652
- Math.max(1, formattingContext.width - 4),
653
- false,
654
- false,
655
- );
656
-
945
+ const tags = formatTags(formattingContext.tagLines, formattingContext.width, false, false);
657
946
  // The formatted comment content, before tags are appended.
658
947
  const outputLines = [...prose];
659
948
 
@@ -692,15 +981,8 @@ export function formatJSDocTagFormatting(commentText, formattingOptions) {
692
981
  const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
693
982
  // The prose, rewrapped to the comment's available width.
694
983
  const prose = formatProse(formattingContext.proseLines, formattingContext.width, false);
695
-
696
984
  // The tag lines, with spacing and grouping normalised.
697
- const tags = formatTags(
698
- formattingContext.tagLines,
699
- Math.max(1, formattingContext.width - 4),
700
- false,
701
- true,
702
- );
703
-
985
+ const tags = formatTags(formattingContext.tagLines, formattingContext.width, false, true);
704
986
  // The formatted comment content, before tags are appended.
705
987
  const outputLines = [...prose];
706
988
 
@@ -726,15 +1008,8 @@ export function formatJSDocPunctuation(commentText, formattingOptions) {
726
1008
  const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
727
1009
  // The prose, capitalised and punctuated as sentences.
728
1010
  const prose = formatUnwrappedProse(formattingContext.proseLines, true, formattingContext.indent);
729
-
730
1011
  // The tag lines, with descriptions punctuated as sentences.
731
- const tags = formatTags(
732
- formattingContext.tagLines,
733
- Math.max(1, formattingContext.width - 4),
734
- true,
735
- false,
736
- );
737
-
1012
+ const tags = formatTags(formattingContext.tagLines, formattingContext.width, true, false);
738
1013
  // The formatted comment content, before tags are appended.
739
1014
  const outputLines = [...prose];
740
1015
 
@@ -760,15 +1035,8 @@ export function formatJSDocWrapping(commentText, formattingOptions) {
760
1035
  const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
761
1036
  // The prose, rewrapped to the comment's available width.
762
1037
  const prose = formatProse(formattingContext.proseLines, formattingContext.width, false);
763
-
764
1038
  // The tag lines, without spacing or grouping normalisation.
765
- const tags = formatTags(
766
- formattingContext.tagLines,
767
- Math.max(1, formattingContext.width - 4),
768
- false,
769
- false,
770
- );
771
-
1039
+ const tags = formatTags(formattingContext.tagLines, formattingContext.width, false, false);
772
1040
  // The formatted comment content, before tags are appended.
773
1041
  const outputLines = [...prose];
774
1042
 
@@ -301,28 +301,68 @@ export function isLeadingComment(sourceCode, comment, previous) {
301
301
  * The source node.
302
302
  *
303
303
  * @returns {boolean}
304
- * Whether an ordinary line comment immediately precedes the node.
304
+ * Whether an ordinary line comment sits directly above the node. Tool
305
+ * directive comments may sit between them.
305
306
  */
306
307
  export function hasImmediateLineComment(sourceCode, node) {
307
- // Finds the closest preceding comment.
308
- const comment = sourceCode
309
- .getAllComments()
310
- .findLast((candidate) => candidate.range[1] <= node.range[0]);
308
+ // The closest comment above the node, looking past tool directive comments.
309
+ const comment = getImmediateNonDirectiveComment(sourceCode, node);
311
310
 
312
- if (comment?.type !== "Line" || isDirectiveComment(comment)) {
311
+ if (comment?.type !== "Line") {
313
312
  return false;
314
313
  }
315
314
 
316
315
  // Checks the comments immediately around the node.
317
316
  const { next, previous } = getCommentNeighbours(sourceCode, comment);
318
- // Confirms there is no blank line before the node.
319
- const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
320
317
 
321
- return (
322
- next?.range[0] === node.range[0] &&
323
- isLeadingComment(sourceCode, comment, previous) &&
324
- /^\r?\n[ \t]*$/.test(gap)
325
- );
318
+ return next?.range[0] === node.range[0] && isLeadingComment(sourceCode, comment, previous);
319
+ }
320
+
321
+ /**
322
+ * Return the closest comment above a node, skipping tool directive comments
323
+ * such as `// eslint-disable-next-line`. The comment, each directive and the
324
+ * node must each start on the line after the one before, with no blank lines.
325
+ *
326
+ * @param {object} sourceCode
327
+ * The Oxlint source code object.
328
+ * @param {object} node
329
+ * The source node.
330
+ *
331
+ * @returns {object|null}
332
+ * The comment above the node, or null when no comment sits directly above
333
+ * the node.
334
+ */
335
+ export function getImmediateNonDirectiveComment(sourceCode, node) {
336
+ // Comments before the node, in source order.
337
+ const comments = sourceCode
338
+ .getAllComments()
339
+ .filter((comment) => comment.range[1] <= node.range[0]);
340
+
341
+ // The position of the closest comment that is not a tool directive.
342
+ const commentIndex = comments.findLastIndex((comment) => !isDirectiveComment(comment));
343
+
344
+ if (commentIndex < 0) {
345
+ return null;
346
+ }
347
+
348
+ // The comment that may document the node.
349
+ const comment = comments[commentIndex];
350
+
351
+ // Where the comment or directive checked last ends.
352
+ let previousEnd = comment.range[1];
353
+
354
+ for (const next of [...comments.slice(commentIndex + 1), node]) {
355
+ // The text between the previous item and this one.
356
+ const gap = sourceCode.text.slice(previousEnd, next.range[0]);
357
+
358
+ if (!/^\r?\n[ \t]*$/.test(gap)) {
359
+ return null;
360
+ }
361
+
362
+ previousEnd = next.range[1];
363
+ }
364
+
365
+ return comment;
326
366
  }
327
367
 
328
368
  /**
@@ -334,26 +374,19 @@ export function hasImmediateLineComment(sourceCode, node) {
334
374
  * The source node.
335
375
  *
336
376
  * @returns {boolean}
337
- * Whether an ordinary block comment immediately precedes the node.
377
+ * Whether an ordinary block comment sits directly above the node. Tool
378
+ * directive comments may sit between them.
338
379
  */
339
380
  export function hasImmediateBlockComment(sourceCode, node) {
340
- // Finds the closest preceding comment.
341
- const comment = sourceCode
342
- .getAllComments()
343
- .findLast((candidate) => candidate.range[1] <= node.range[0]);
381
+ // The closest comment above the node, looking past tool directive comments.
382
+ const comment = getImmediateNonDirectiveComment(sourceCode, node);
344
383
 
345
- if (comment?.type !== "Block" || isDirectiveComment(comment)) {
384
+ if (comment?.type !== "Block") {
346
385
  return false;
347
386
  }
348
387
 
349
388
  // Checks the comments immediately around the node.
350
389
  const { next, previous } = getCommentNeighbours(sourceCode, comment);
351
- // Confirms there is no blank line before the node.
352
- const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
353
390
 
354
- return (
355
- next?.range[0] === node.range[0] &&
356
- isLeadingComment(sourceCode, comment, previous) &&
357
- /^\r?\n[ \t]*$/.test(gap)
358
- );
391
+ return next?.range[0] === node.range[0] && isLeadingComment(sourceCode, comment, previous);
359
392
  }
@@ -1,8 +1,22 @@
1
1
  import { getDisplayWidth } from "./source.js";
2
2
 
3
+ // One word of comment text. A backtick code span counts as part of the word
4
+ // around it, so wrapping never splits the code inside it across lines.
5
+ const wordPattern = /(?:`[^`]*`|[^\s`]+|`)+/g;
6
+ // Text that is only an inline code span, which reads as code, not a sentence.
7
+ const codeSpanOnlyPattern = /^`[^`]*`$/;
8
+ // An inline code span anywhere in the text.
9
+ const codeSpanPattern = /`[^`]*`/;
10
+ // Text that is only a quotation, in straight or curly quotes.
11
+ const quoteOnlyPattern = /^(?:"[^"]*"|'[^']*'|“[^”]*”|‘[^’]*’)$/;
12
+
3
13
  /**
4
14
  * Wrap words to a maximum line width.
5
15
  *
16
+ * A code span is never split: one wider than the line goes whole on its own
17
+ * line. Any other word wider than the line also gets its own line, and is cut
18
+ * at the width only when nothing comes before it on the line.
19
+ *
6
20
  * @param {string} text
7
21
  * The text to wrap.
8
22
  * @param {number} width
@@ -12,8 +26,8 @@ import { getDisplayWidth } from "./source.js";
12
26
  * Wrapped lines.
13
27
  */
14
28
  export function wrapWords(text, width) {
15
- // The text's individual words.
16
- const words = text.trim().split(/\s+/).filter(Boolean);
29
+ // The text's words, with code spans kept whole.
30
+ const words = text.trim().match(wordPattern) ?? [];
17
31
  // The wrapped lines, built up in place.
18
32
  const lines = [];
19
33
 
@@ -22,6 +36,13 @@ export function wrapWords(text, width) {
22
36
 
23
37
  for (const word of words) {
24
38
  if (word.length > width && currentLine === "") {
39
+ // Code stays whole even when it overflows the line.
40
+ if (codeSpanPattern.test(word)) {
41
+ lines.push(word);
42
+
43
+ continue;
44
+ }
45
+
25
46
  for (let index = 0; index < word.length; index += width) {
26
47
  lines.push(word.slice(index, index + width));
27
48
  }
@@ -74,8 +95,8 @@ export function refillCommentLines(lines, maximumLineLength) {
74
95
  break;
75
96
  }
76
97
 
77
- // The words still waiting on the following line.
78
- const nextWords = nextLine.text.split(/\s+/).filter(Boolean);
98
+ // The next line's words, with code spans kept whole.
99
+ const nextWords = nextLine.text.match(wordPattern) ?? [];
79
100
 
80
101
  // Whether the following line was removed after giving up all its
81
102
  // words.
@@ -180,6 +201,9 @@ export function formatSentence(text) {
180
201
  /**
181
202
  * Capitalise the first letter of sentence text.
182
203
  *
204
+ * Text that starts with anything other than a letter, such as a code span, a
205
+ * quote or an emoji, is returned unchanged.
206
+ *
183
207
  * @param {string} text
184
208
  * The sentence text.
185
209
  *
@@ -194,15 +218,14 @@ export function capitaliseSentence(text) {
194
218
  return text;
195
219
  }
196
220
 
197
- // The index of the first letter character, ignoring leading punctuation.
198
- const firstLetter = trimmedText.search(/\p{L}/u);
199
-
200
- if (firstLetter < 0) {
221
+ // Code, quoted text and emoji keep their own casing, so only a sentence
222
+ // that starts with a letter is capitalised.
223
+ if (!/^\p{L}/u.test(trimmedText)) {
201
224
  return text;
202
225
  }
203
226
 
204
- // The leading word, starting from the first letter character.
205
- const leadingWord = trimmedText.slice(firstLetter).match(/^\p{L}[\p{L}\p{N}]*/u)?.[0] ?? "";
227
+ // The leading word, before any space or punctuation.
228
+ const leadingWord = trimmedText.match(/^\p{L}[\p{L}\p{N}]*/u)?.[0] ?? "";
206
229
 
207
230
  // A camelCase word (lowercase start, later uppercase) is a code identifier
208
231
  // and must keep its own casing rather than sentence casing.
@@ -211,15 +234,20 @@ export function capitaliseSentence(text) {
211
234
  }
212
235
 
213
236
  // The first letter character.
214
- const letter = trimmedText[firstLetter];
237
+ const letter = trimmedText[0];
215
238
  // The text with its first letter capitalised.
216
- const formattedText = `${trimmedText.slice(0, firstLetter)}${letter.toLocaleUpperCase()}${trimmedText.slice(firstLetter + 1)}`;
239
+ const formattedText = `${letter.toLocaleUpperCase()}${trimmedText.slice(1)}`;
217
240
 
218
241
  return text.replace(trimmedText, formattedText);
219
242
  }
220
243
 
221
244
  /**
222
- * Add terminal punctuation to sentence text.
245
+ * Add a full stop to sentence text that has no closing punctuation.
246
+ *
247
+ * Text ending in a colon introduces what follows, and text that is only a code
248
+ * span or a quotation is not a sentence, so both are returned unchanged. A
249
+ * sentence that ends in a code span or quotation gets its full stop after the
250
+ * closing mark.
223
251
  *
224
252
  * @param {string} text
225
253
  * The sentence text.
@@ -231,7 +259,13 @@ export function addTerminalPunctuation(text) {
231
259
  // The sentence text, without leading or trailing whitespace.
232
260
  const trimmedText = text.trim();
233
261
 
234
- if (trimmedText === "" || trimmedText.startsWith("@") || /[.!?]$/.test(trimmedText)) {
262
+ if (
263
+ trimmedText === "" ||
264
+ trimmedText.startsWith("@") ||
265
+ /[.!?:]$/.test(trimmedText) ||
266
+ codeSpanOnlyPattern.test(trimmedText) ||
267
+ quoteOnlyPattern.test(trimmedText)
268
+ ) {
235
269
  return text;
236
270
  }
237
271
 
package/comments.json CHANGED
@@ -19,6 +19,7 @@
19
19
  {
20
20
  "files": ["**/*.test.*", "**/*.spec.*", "**/test/**"],
21
21
  "rules": {
22
+ "comments/function-documentation": ["error", { "ignoreInlineArrows": true }],
22
23
  "comments/variable-declarations": ["error", { "rootOnly": true }]
23
24
  }
24
25
  }
package/layers.js ADDED
@@ -0,0 +1,60 @@
1
+ import base from "./base.json" with { type: "json" };
2
+ import comments from "./comments.json" with { type: "json" };
3
+ import vueConfig from "./vue.json" with { type: "json" };
4
+
5
+ // The Vue layer. It includes base as an object because Oxlint rejects the file
6
+ // path that vue.json uses to extend base.
7
+ export const vue = { ...vueConfig, extends: [base] };
8
+
9
+ /**
10
+ * Build a Vite+ lint block from the selected layers and local settings.
11
+ *
12
+ * @param {object[]} layers
13
+ * The layer objects to extend. When two layers set the same env or global,
14
+ * the later layer's value is used.
15
+ * @param {object} local
16
+ * Project settings that take priority over the layers. Its extends list is
17
+ * ignored, so every layer must be passed in layers.
18
+ *
19
+ * @returns {object}
20
+ * A lint block with inherited environments and globals at the top level.
21
+ */
22
+ export function lintConfig(layers, local = {}) {
23
+ // The environments inherited from the selected layers.
24
+ const env = {};
25
+ // The globals inherited from the selected layers.
26
+ const globals = {};
27
+
28
+ for (const layer of layers) {
29
+ collectEnvAndGlobals(layer, env, globals);
30
+ }
31
+
32
+ return {
33
+ ...local,
34
+ env: { ...env, ...local.env },
35
+ globals: { ...globals, ...local.globals },
36
+ extends: layers,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Copy a layer's env and globals into the collected values. The layers it
42
+ * extends are copied first, so the layer's own values win.
43
+ *
44
+ * @param {object} layer
45
+ * The layer to read, along with every layer it extends.
46
+ * @param {object} env
47
+ * The collected environments, updated in place.
48
+ * @param {object} globals
49
+ * The collected globals, updated in place.
50
+ */
51
+ function collectEnvAndGlobals(layer, env, globals) {
52
+ for (const extendedLayer of layer.extends ?? []) {
53
+ collectEnvAndGlobals(extendedLayer, env, globals);
54
+ }
55
+
56
+ Object.assign(env, layer.env);
57
+ Object.assign(globals, layer.globals);
58
+ }
59
+
60
+ export { base, comments };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/lint-config",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Shared oxlint configuration for Lewis Howles projects",
5
5
  "keywords": [
6
6
  "config",
@@ -24,6 +24,7 @@
24
24
  "comments",
25
25
  "comments.json",
26
26
  "imports.json",
27
+ "layers.js",
27
28
  "vue.json",
28
29
  "README.md"
29
30
  ],
@@ -36,6 +37,7 @@
36
37
  "./comments.json": "./comments.json",
37
38
  "./comments/plugin": "./comments/plugin.js",
38
39
  "./imports.json": "./imports.json",
40
+ "./layers": "./layers.js",
39
41
  "./vue.json": "./vue.json"
40
42
  },
41
43
  "publishConfig": {
@@ -46,15 +48,15 @@
46
48
  "lint:fix": "vp check --fix",
47
49
  "prepare": "vp config --no-agent",
48
50
  "publint": "publint",
49
- "test:unit": "node --test test/comments/*.test.js"
51
+ "test:unit": "node --test test/comments/*.test.js test/layers/*.test.js"
50
52
  },
51
53
  "devDependencies": {
52
54
  "publint": "^0.3.22",
53
- "vite-plus": "0.2.8"
55
+ "vite-plus": "1.0.0"
54
56
  },
55
57
  "peerDependencies": {
56
58
  "@stylistic/eslint-plugin": "*",
57
- "vite-plus": "0.2.8"
59
+ "vite-plus": "^1.0.0"
58
60
  },
59
61
  "engines": {
60
62
  "node": ">=20"