eslint-plugin-markdown-preferences 0.8.0 → 0.9.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/README.md CHANGED
@@ -28,7 +28,7 @@ For detailed usage instructions, rule configurations, and examples, visit our co
28
28
 
29
29
  ## 💿 Installation
30
30
 
31
- ```bash
31
+ ```sh
32
32
  npm install --save-dev eslint eslint-plugin-markdown-preferences
33
33
  ```
34
34
 
@@ -98,8 +98,10 @@ The rules with the following star ⭐ are included in the configs.
98
98
 
99
99
  | Rule ID | Description | Fixable | RECOMMENDED |
100
100
  |:--------|:------------|:-------:|:-----------:|
101
+ | [markdown-preferences/canonical-code-block-language](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/canonical-code-block-language.html) | enforce canonical language names in code blocks | 🔧 | |
101
102
  | [markdown-preferences/definitions-last](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/definitions-last.html) | require link definitions and footnote definitions to be placed at the end of the document | 🔧 | |
102
103
  | [markdown-preferences/hard-linebreak-style](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/hard-linebreak-style.html) | enforce consistent hard linebreak style. | 🔧 | ⭐ |
104
+ | [markdown-preferences/heading-casing](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/heading-casing.html) | enforce consistent casing in headings. | 🔧 | |
103
105
  | [markdown-preferences/no-trailing-spaces](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-trailing-spaces.html) | disallow trailing whitespace at the end of lines in Markdown files. | 🔧 | |
104
106
  | [markdown-preferences/prefer-link-reference-definitions](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/prefer-link-reference-definitions.html) | enforce using link reference definitions instead of inline links | 🔧 | |
105
107
  | [markdown-preferences/sort-definitions](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/sort-definitions.html) | enforce a specific order for link definitions and footnote definitions | 🔧 | |
package/lib/index.d.ts CHANGED
@@ -10,6 +10,11 @@ declare module 'eslint' {
10
10
  }
11
11
  }
12
12
  interface RuleOptions {
13
+ /**
14
+ * enforce canonical language names in code blocks
15
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/canonical-code-block-language.html
16
+ */
17
+ 'markdown-preferences/canonical-code-block-language'?: Linter.RuleEntry<MarkdownPreferencesCanonicalCodeBlockLanguage>;
13
18
  /**
14
19
  * require link definitions and footnote definitions to be placed at the end of the document
15
20
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/definitions-last.html
@@ -20,6 +25,11 @@ interface RuleOptions {
20
25
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/hard-linebreak-style.html
21
26
  */
22
27
  'markdown-preferences/hard-linebreak-style'?: Linter.RuleEntry<MarkdownPreferencesHardLinebreakStyle>;
28
+ /**
29
+ * enforce consistent casing in headings.
30
+ * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/heading-casing.html
31
+ */
32
+ 'markdown-preferences/heading-casing'?: Linter.RuleEntry<MarkdownPreferencesHeadingCasing>;
23
33
  /**
24
34
  * disallow text backslash at the end of a line.
25
35
  * @see https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/no-text-backslash-linebreak.html
@@ -51,9 +61,20 @@ interface RuleOptions {
51
61
  */
52
62
  'markdown-preferences/sort-definitions'?: Linter.RuleEntry<MarkdownPreferencesSortDefinitions>;
53
63
  }
64
+ type MarkdownPreferencesCanonicalCodeBlockLanguage = [] | [{
65
+ languages?: {
66
+ [k: string]: string;
67
+ };
68
+ }];
54
69
  type MarkdownPreferencesHardLinebreakStyle = [] | [{
55
70
  style?: ("backslash" | "spaces");
56
71
  }];
72
+ type MarkdownPreferencesHeadingCasing = [] | [{
73
+ style?: ("Title Case" | "Sentence case");
74
+ preserveWords?: string[];
75
+ ignorePatterns?: string[];
76
+ minorWords?: string[];
77
+ }];
57
78
  type MarkdownPreferencesNoTrailingSpaces = [] | [{
58
79
  skipBlankLines?: boolean;
59
80
  ignoreComments?: boolean;
@@ -107,19 +128,27 @@ declare namespace meta_d_exports {
107
128
  export { name, version };
108
129
  }
109
130
  declare const name: "eslint-plugin-markdown-preferences";
110
- declare const version: "0.8.0";
131
+ declare const version: "0.9.0";
111
132
  //#endregion
112
133
  //#region src/index.d.ts
113
134
  declare const configs: {
114
135
  recommended: typeof recommended_d_exports;
115
136
  };
116
137
  declare const rules: Record<string, RuleDefinition>;
138
+ declare const resources: {
139
+ defaultPreserveWords: string[];
140
+ defaultMinorWords: string[];
141
+ };
117
142
  declare const _default: {
118
143
  meta: typeof meta_d_exports;
119
144
  configs: {
120
145
  recommended: typeof recommended_d_exports;
121
146
  };
122
147
  rules: Record<string, RuleDefinition<_eslint_core0.RuleDefinitionTypeOptions>>;
148
+ resources: {
149
+ defaultPreserveWords: string[];
150
+ defaultMinorWords: string[];
151
+ };
123
152
  };
124
153
  //#endregion
125
- export { RuleOptions, configs, _default as default, meta_d_exports as meta, rules };
154
+ export { RuleOptions, configs, _default as default, meta_d_exports as meta, resources, rules };
package/lib/index.js CHANGED
@@ -25,6 +25,98 @@ function createRule(ruleName, rule) {
25
25
  };
26
26
  }
27
27
 
28
+ //#endregion
29
+ //#region src/rules/canonical-code-block-language.ts
30
+ const DEFAULT_LANGUAGES = {
31
+ javascript: "js",
32
+ jsx: "js",
33
+ mjs: "js",
34
+ cjs: "js",
35
+ typescript: "ts",
36
+ tsx: "ts",
37
+ mts: "ts",
38
+ cts: "ts",
39
+ python: "py",
40
+ bash: "sh",
41
+ shell: "sh",
42
+ zsh: "sh",
43
+ yml: "yaml",
44
+ markdown: "md",
45
+ rust: "rs",
46
+ golang: "go",
47
+ cplusplus: "cpp",
48
+ "c++": "cpp",
49
+ postgresql: "sql",
50
+ mysql: "sql",
51
+ sqlite: "sql"
52
+ };
53
+ var canonical_code_block_language_default = createRule("canonical-code-block-language", {
54
+ meta: {
55
+ type: "suggestion",
56
+ docs: {
57
+ description: "enforce canonical language names in code blocks",
58
+ categories: [],
59
+ listCategory: "Stylistic"
60
+ },
61
+ fixable: "code",
62
+ hasSuggestions: false,
63
+ schema: [{
64
+ type: "object",
65
+ properties: { languages: {
66
+ type: "object",
67
+ patternProperties: { "^[\\s\\S]+$": { type: "string" } },
68
+ additionalProperties: false
69
+ } },
70
+ additionalProperties: false
71
+ }],
72
+ messages: { useCanonical: "Use canonical language name \"{{canonical}}\" instead of \"{{current}}\"." }
73
+ },
74
+ create(context) {
75
+ const sourceCode = context.sourceCode;
76
+ const languages = context.options[0]?.languages || DEFAULT_LANGUAGES;
77
+ return { code(node) {
78
+ if (!node.lang || !languages[node.lang]) return;
79
+ const canonical = languages[node.lang];
80
+ const current = node.lang;
81
+ if (current === canonical) return;
82
+ const nodeRange = sourceCode.getRange(node);
83
+ const nodeText = sourceCode.text.slice(nodeRange[0], nodeRange[1]);
84
+ const fenceRegex = /^(`{3,}|~{3,})(\w*)(?:\s.*)?$/mu;
85
+ const fenceMatch = fenceRegex.exec(nodeText.split("\n")[0]);
86
+ if (!fenceMatch) return;
87
+ const [, fence, langInfo] = fenceMatch;
88
+ const langStart = nodeRange[0] + fence.length;
89
+ const langEnd = langStart + langInfo.length;
90
+ const nodeLoc = sourceCode.getLoc(node);
91
+ const beforeFence = sourceCode.text.slice(nodeRange[0], langStart);
92
+ const beforeLines = beforeFence.split(/\n/u);
93
+ const line = nodeLoc.start.line + beforeLines.length - 1;
94
+ const column = (beforeLines.length === 1 ? nodeLoc.start.column : 0) + (beforeLines.at(-1) || "").length;
95
+ context.report({
96
+ node,
97
+ loc: {
98
+ start: {
99
+ line,
100
+ column
101
+ },
102
+ end: {
103
+ line,
104
+ column: column + langInfo.length
105
+ }
106
+ },
107
+ messageId: "useCanonical",
108
+ data: {
109
+ canonical,
110
+ current
111
+ },
112
+ fix(fixer) {
113
+ return fixer.replaceTextRange([langStart, langEnd], canonical);
114
+ }
115
+ });
116
+ } };
117
+ }
118
+ });
119
+
28
120
  //#endregion
29
121
  //#region src/rules/definitions-last.ts
30
122
  var definitions_last_default = createRule("definitions-last", {
@@ -111,6 +203,781 @@ var hard_linebreak_style_default = createRule("hard-linebreak-style", {
111
203
  }
112
204
  });
113
205
 
206
+ //#endregion
207
+ //#region src/utils/regexp.ts
208
+ const RE_REGEXP_STR = /^\/(.+)\/([A-Za-z]*)$/u;
209
+ /**
210
+ * Convert a string to the `RegExp`.
211
+ * Normal strings (e.g. `"foo"`) is converted to `/^foo$/` of `RegExp`.
212
+ * Strings like `"/^foo/i"` are converted to `/^foo/i` of `RegExp`.
213
+ *
214
+ * @param {string} string The string to convert.
215
+ * @returns {RegExp} Returns the `RegExp`.
216
+ */
217
+ function toRegExp(string) {
218
+ const parts = RE_REGEXP_STR.exec(string);
219
+ if (parts) return new RegExp(parts[1], parts[2]);
220
+ return { test: (s) => s === string };
221
+ }
222
+ /**
223
+ * Checks whether given string is regexp string
224
+ * @param {string} string
225
+ * @returns {boolean}
226
+ */
227
+ function isRegExp(string) {
228
+ return Boolean(RE_REGEXP_STR.test(string));
229
+ }
230
+
231
+ //#endregion
232
+ //#region src/resources/preserve-words.ts
233
+ const defaultPreserveWords = [
234
+ "JavaScript",
235
+ "TypeScript",
236
+ "ECMAScript",
237
+ "ES6",
238
+ ...(function* () {
239
+ const end = (/* @__PURE__ */ new Date()).getFullYear() + 3;
240
+ for (let i = 2015; i <= end; i++) yield `ES${i}`;
241
+ })(),
242
+ "PHP",
243
+ "Python",
244
+ "Java",
245
+ "C#",
246
+ "C++",
247
+ "Rust",
248
+ "Go",
249
+ "go",
250
+ "Swift",
251
+ "Kotlin",
252
+ "Dart",
253
+ "Ruby",
254
+ "Scala",
255
+ "Perl",
256
+ "R",
257
+ "MATLAB",
258
+ "Lua",
259
+ "Haskell",
260
+ "Elixir",
261
+ "Clojure",
262
+ "F#",
263
+ "OCaml",
264
+ "Zig",
265
+ "V",
266
+ "Nim",
267
+ "Crystal",
268
+ "Gleam",
269
+ "Odin",
270
+ "Carbon",
271
+ "HTML",
272
+ "CSS",
273
+ "Sass",
274
+ "SCSS",
275
+ "Less",
276
+ "Stylus",
277
+ "JSON",
278
+ "XML",
279
+ "PDF",
280
+ "CSV",
281
+ "YAML",
282
+ "TOML",
283
+ "Markdown",
284
+ "LaTeX",
285
+ "Parquet",
286
+ "Avro",
287
+ "Protobuf",
288
+ "MessagePack",
289
+ "BSON",
290
+ "HDF5",
291
+ "Apache Arrow",
292
+ "ORC",
293
+ "SQL",
294
+ "GraphQL",
295
+ "React",
296
+ "Vue",
297
+ "Angular",
298
+ "Redux",
299
+ "Svelte",
300
+ "SvelteKit",
301
+ "Preact",
302
+ "Solid",
303
+ "Alpine.js",
304
+ "Lit",
305
+ "Stencil",
306
+ "Ember.js",
307
+ "Backbone.js",
308
+ "jQuery",
309
+ "D3.js",
310
+ "Three.js",
311
+ "Chart.js",
312
+ "Plotly",
313
+ "Astro",
314
+ "Remix",
315
+ "Qwik",
316
+ "SolidJS",
317
+ "Vike",
318
+ "Node.js",
319
+ "Deno",
320
+ "Bun",
321
+ "Electron",
322
+ "Tauri",
323
+ "Next.js",
324
+ "Nuxt.js",
325
+ "Gatsby",
326
+ "Express.js",
327
+ "NestJS",
328
+ "FastAPI",
329
+ "Django",
330
+ "Flask",
331
+ "Ruby on Rails",
332
+ "Spring Boot",
333
+ "Laravel",
334
+ "Phoenix",
335
+ "Actix",
336
+ "Axum",
337
+ "Rocket",
338
+ "Flutter",
339
+ "React Native",
340
+ "Ionic",
341
+ "Xamarin",
342
+ "Expo",
343
+ "Unity",
344
+ "Unreal Engine",
345
+ "ESLint",
346
+ "Prettier",
347
+ "Biome",
348
+ "oxc",
349
+ "swc",
350
+ "Webpack",
351
+ "Vite",
352
+ "Babel",
353
+ "Workbox",
354
+ "Rollup",
355
+ "Parcel",
356
+ "esbuild",
357
+ "Turbo",
358
+ "Turborepo",
359
+ "Nx",
360
+ "Lerna",
361
+ "Rush",
362
+ "npm",
363
+ "yarn",
364
+ "pnpm",
365
+ "bun",
366
+ "bower",
367
+ "composer",
368
+ "pip",
369
+ "conda",
370
+ "Maven",
371
+ "Gradle",
372
+ "SBT",
373
+ "Cargo",
374
+ "homebrew",
375
+ "chocolatey",
376
+ "CocoaPods",
377
+ "Carthage",
378
+ "Swift Package Manager",
379
+ "VS Code",
380
+ "Visual Studio Code",
381
+ "Vim",
382
+ "Neovim",
383
+ "Emacs",
384
+ "Sublime Text",
385
+ "Atom",
386
+ "Brackets",
387
+ "brackets",
388
+ "Visual Studio",
389
+ "IntelliJ IDEA",
390
+ "WebStorm",
391
+ "PHPStorm",
392
+ "PyCharm",
393
+ "Android Studio",
394
+ "Xcode",
395
+ "Docker",
396
+ "Kubernetes",
397
+ "Helm",
398
+ "CI / CD",
399
+ "DevOps",
400
+ "GitOps",
401
+ "IaC",
402
+ "Infrastructure as Code",
403
+ "SaaS",
404
+ "PaaS",
405
+ "IaaS",
406
+ "CDN",
407
+ "Load Balancer",
408
+ "API Gateway",
409
+ "Microservices",
410
+ "Serverless",
411
+ "Lambda",
412
+ "Cloud Functions",
413
+ "Container Registry",
414
+ "Prometheus",
415
+ "Grafana",
416
+ "Terraform",
417
+ "Ansible",
418
+ "Jenkins",
419
+ "GitHub Actions",
420
+ "GitLab CI",
421
+ "CircleCI",
422
+ "Travis CI",
423
+ "Azure DevOps",
424
+ "TeamCity",
425
+ "Bamboo",
426
+ "Buildkite",
427
+ "Drone CI",
428
+ "AWS",
429
+ "Amazon Web Services",
430
+ "Azure",
431
+ "GCP",
432
+ "Google Cloud Platform",
433
+ "Heroku",
434
+ "Vercel",
435
+ "Netlify",
436
+ "Railway",
437
+ "Render",
438
+ "Fly.io",
439
+ "Cloudflare",
440
+ "DigitalOcean",
441
+ "API",
442
+ "APIs",
443
+ "REST",
444
+ "RESTful",
445
+ "gRPC",
446
+ "HTTP",
447
+ "HTTPS",
448
+ "WebSocket",
449
+ "SOAP",
450
+ "XML-RPC",
451
+ "JSON-RPC",
452
+ "CLI",
453
+ "SDK",
454
+ "URL",
455
+ "URI",
456
+ "UUID",
457
+ "GUID",
458
+ "CRUD",
459
+ "ACID",
460
+ "BASE",
461
+ "CAP Theorem",
462
+ "JWT",
463
+ "OAuth",
464
+ "OAuth2",
465
+ "OpenID Connect",
466
+ "SAML",
467
+ "SSO",
468
+ "MFA",
469
+ "2FA",
470
+ "CORS",
471
+ "CSRF",
472
+ "XSS",
473
+ "SSH",
474
+ "FTP",
475
+ "SFTP",
476
+ "SMTP",
477
+ "IMAP",
478
+ "POP3",
479
+ "TCP",
480
+ "UDP",
481
+ "IP",
482
+ "IPv4",
483
+ "IPv6",
484
+ "DNS",
485
+ "DHCP",
486
+ "VPN",
487
+ "SSL",
488
+ "TLS",
489
+ "LDAP",
490
+ "PostgreSQL",
491
+ "MySQL",
492
+ "SQLite",
493
+ "MariaDB",
494
+ "Oracle",
495
+ "SQL Server",
496
+ "CockroachDB",
497
+ "PlanetScale",
498
+ "Neon",
499
+ "NoSQL",
500
+ "MongoDB",
501
+ "DynamoDB",
502
+ "Cassandra",
503
+ "CouchDB",
504
+ "Neo4j",
505
+ "ArangoDB",
506
+ "FaunaDB",
507
+ "Firebase",
508
+ "Supabase",
509
+ "Redis",
510
+ "ElasticSearch",
511
+ "Solr",
512
+ "InfluxDB",
513
+ "TimescaleDB",
514
+ "Prisma",
515
+ "TypeORM",
516
+ "Sequelize",
517
+ "Mongoose",
518
+ "Drizzle",
519
+ "Knex.js",
520
+ "Objection.js",
521
+ "Bookshelf.js",
522
+ "QA",
523
+ "QC",
524
+ "TDD",
525
+ "BDD",
526
+ "E2E",
527
+ "Unit Testing",
528
+ "Integration Testing",
529
+ "Jest",
530
+ "Mocha",
531
+ "Chai",
532
+ "Jasmine",
533
+ "Karma",
534
+ "Vitest",
535
+ "Ava",
536
+ "Tape",
537
+ "Cypress",
538
+ "Playwright",
539
+ "Selenium",
540
+ "Puppeteer",
541
+ "WebDriver",
542
+ "TestCafe",
543
+ "SonarQube",
544
+ "Husky",
545
+ "lint-staged",
546
+ "commitizen",
547
+ "semantic-release",
548
+ "Codecov",
549
+ "CodeClimate",
550
+ "TensorFlow",
551
+ "PyTorch",
552
+ "Keras",
553
+ "Scikit-learn",
554
+ "Pandas",
555
+ "NumPy",
556
+ "OpenCV",
557
+ "Hugging Face",
558
+ "LangChain",
559
+ "OpenAI",
560
+ "Anthropic",
561
+ "Jupyter",
562
+ "MLflow",
563
+ "Weights & Biases",
564
+ "CUDA",
565
+ "ONNX",
566
+ "GPT",
567
+ "BERT",
568
+ "Transformer",
569
+ "Claude",
570
+ "Gemini",
571
+ "LLaMA",
572
+ "Stable Diffusion",
573
+ "DALL-E",
574
+ "Midjourney",
575
+ "AutoML",
576
+ "ETL",
577
+ "ELT",
578
+ "Big Data",
579
+ "Data Lake",
580
+ "Data Warehouse",
581
+ "OLAP",
582
+ "OLTP",
583
+ "Apache Spark",
584
+ "Apache Kafka",
585
+ "Apache Airflow",
586
+ "Hadoop",
587
+ "Snowflake",
588
+ "Databricks",
589
+ "Tableau",
590
+ "Power BI",
591
+ "Looker",
592
+ "OWASP",
593
+ "SAST",
594
+ "DAST",
595
+ "IAST",
596
+ "SCA",
597
+ "Penetration Testing",
598
+ "Vulnerability Assessment",
599
+ "RBAC",
600
+ "ABAC",
601
+ "Zero Trust",
602
+ "PKI",
603
+ "HSM",
604
+ "WAF",
605
+ "DDoS",
606
+ "PWA",
607
+ "SPA",
608
+ "SSR",
609
+ "SSG",
610
+ "CSR",
611
+ "JAMstack",
612
+ "Headless CMS",
613
+ "Edge Computing",
614
+ "WebAssembly",
615
+ "WASM",
616
+ "Service Worker",
617
+ "Web Components",
618
+ "Micro Frontends",
619
+ "BFF",
620
+ "Backend for Frontend",
621
+ "GraphQL",
622
+ "tRPC",
623
+ "gRPC-Web",
624
+ "WebRTC",
625
+ "WebGL",
626
+ "WebGPU",
627
+ "Material UI",
628
+ "Ant Design",
629
+ "Chakra UI",
630
+ "React Bootstrap",
631
+ "Semantic UI React",
632
+ "Blueprint",
633
+ "Mantine",
634
+ "NextUI",
635
+ "Arco Design",
636
+ "Tailwind CSS",
637
+ "Bootstrap",
638
+ "Bulma",
639
+ "Foundation",
640
+ "Semantic UI",
641
+ "Materialize",
642
+ "Spectre.css",
643
+ "Tachyons",
644
+ "PureCSS",
645
+ "styled-components",
646
+ "CSS-in-JS",
647
+ "Emotion",
648
+ "JSS",
649
+ "Styled System",
650
+ "Stitches",
651
+ "Vanilla Extract",
652
+ "Linaria",
653
+ "Aphrodite",
654
+ "Glamorous",
655
+ "Radium",
656
+ "Git",
657
+ "Mercurial",
658
+ "SVN",
659
+ "GitHub",
660
+ "GitLab",
661
+ "Bitbucket",
662
+ "Pull Request",
663
+ "Merge Request",
664
+ "Code Review",
665
+ "Pair Programming",
666
+ "Mob Programming",
667
+ "Docusaurus",
668
+ "GitBook",
669
+ "VitePress",
670
+ "VuePress",
671
+ "Docsify",
672
+ "MkDocs",
673
+ "Sphinx",
674
+ "Jekyll",
675
+ "Hugo",
676
+ "Eleventy",
677
+ "Hexo",
678
+ "Zola",
679
+ "Swagger",
680
+ "OpenAPI",
681
+ "Postman",
682
+ "Insomnia",
683
+ "Redoc",
684
+ "Stoplight",
685
+ "FAQ"
686
+ ];
687
+
688
+ //#endregion
689
+ //#region src/resources/minor-words.ts
690
+ const articles = [
691
+ "a",
692
+ "an",
693
+ "the"
694
+ ];
695
+ const conjunctions = [
696
+ "for",
697
+ "and",
698
+ "nor",
699
+ "but",
700
+ "or",
701
+ "yet",
702
+ "so"
703
+ ];
704
+ const prepositions = [
705
+ "a",
706
+ "as",
707
+ "at",
708
+ "by",
709
+ "ex",
710
+ "in",
711
+ "of",
712
+ "on",
713
+ "re",
714
+ "to",
715
+ "up"
716
+ ];
717
+ const defaultMinorWords = [
718
+ ...articles,
719
+ ...conjunctions,
720
+ ...prepositions
721
+ ];
722
+
723
+ //#endregion
724
+ //#region src/rules/heading-casing.ts
725
+ /**
726
+ * Parse preserve words and phrases from the options
727
+ * - Single words are added to preserveWords
728
+ * - Multi-word phrases are added to preservePhrases and added to preserveWords with no spaces
729
+ */
730
+ function parsePreserveWords(preserveWordsOption) {
731
+ const preserveWords = /* @__PURE__ */ new Map();
732
+ /**
733
+ * Add a single word to the preserveWords map
734
+ */
735
+ function addPreserveWord(word) {
736
+ const lowerWord = word.toLowerCase();
737
+ let list = preserveWords.get(lowerWord);
738
+ if (!list) {
739
+ list = [];
740
+ preserveWords.set(lowerWord, list);
741
+ }
742
+ list.push(word);
743
+ }
744
+ const preservePhrases = /* @__PURE__ */ new Map();
745
+ for (const word of preserveWordsOption) {
746
+ const splitted = word.split(/\s+/);
747
+ if (splitted.length <= 1) addPreserveWord(word);
748
+ else {
749
+ preservePhrases.set(word, splitted);
750
+ addPreserveWord(splitted.join(""));
751
+ }
752
+ }
753
+ return {
754
+ preserveWords,
755
+ preservePhrases: [...preservePhrases.values()].sort((a, b) => b.length - a.length)
756
+ };
757
+ }
758
+ /**
759
+ * Parse text into words with offsets
760
+ */
761
+ function parseText(text, firstNode, lastNode) {
762
+ const words = [];
763
+ const pattern = /(\w+(?:[^\s\w]\w+)*|:\w+:|[^\s\w]+|\s+)/gu;
764
+ let match;
765
+ while ((match = pattern.exec(text)) !== null) {
766
+ const token = match[1];
767
+ if (/^\s+$/.test(token)) continue;
768
+ const punctuation = /^(?::\w+:|[^\s\w]+)$/.test(token);
769
+ words.push({
770
+ word: token,
771
+ offset: match.index,
772
+ punctuation,
773
+ first: false,
774
+ last: false
775
+ });
776
+ }
777
+ if (firstNode) {
778
+ for (const w of words) if (!w.punctuation) {
779
+ w.first = true;
780
+ break;
781
+ }
782
+ }
783
+ if (lastNode) for (let i = words.length - 1; i >= 0; i--) {
784
+ const w = words[i];
785
+ if (!w.punctuation) {
786
+ w.last = true;
787
+ break;
788
+ }
789
+ }
790
+ return words;
791
+ }
792
+ /**
793
+ * Convert a single word based on case style and context
794
+ */
795
+ function convertWord({ word, first, last }, caseStyle, minorWords) {
796
+ if (caseStyle === "Title Case") {
797
+ if (first || last) return {
798
+ word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
799
+ isMinorWord: false
800
+ };
801
+ if (minorWords.some((minorWord) => minorWord.toLowerCase() === word.toLowerCase())) return {
802
+ word: word.toLowerCase(),
803
+ isMinorWord: true
804
+ };
805
+ return {
806
+ word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
807
+ isMinorWord: false
808
+ };
809
+ }
810
+ if (first) return {
811
+ word: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
812
+ isMinorWord: false
813
+ };
814
+ return {
815
+ word: word.toLowerCase(),
816
+ isMinorWord: false
817
+ };
818
+ }
819
+ var heading_casing_default = createRule("heading-casing", {
820
+ meta: {
821
+ type: "suggestion",
822
+ docs: {
823
+ description: "enforce consistent casing in headings.",
824
+ categories: [],
825
+ listCategory: "Stylistic"
826
+ },
827
+ fixable: "code",
828
+ hasSuggestions: false,
829
+ schema: [{
830
+ type: "object",
831
+ properties: {
832
+ style: { enum: ["Title Case", "Sentence case"] },
833
+ preserveWords: {
834
+ type: "array",
835
+ items: { type: "string" },
836
+ description: "Words that should be preserved as-is (case-insensitive matching)"
837
+ },
838
+ ignorePatterns: {
839
+ type: "array",
840
+ items: { type: "string" },
841
+ description: "Regular expression patterns for words to ignore during casing checks"
842
+ },
843
+ minorWords: {
844
+ type: "array",
845
+ items: { type: "string" },
846
+ description: "Words that should not be capitalized in Title Case (unless they're the first or last word)"
847
+ }
848
+ },
849
+ additionalProperties: false
850
+ }],
851
+ messages: {
852
+ expectedTitleCase: "Expected \"{{actual}}\" to be \"{{expected}}\" (Title Case).",
853
+ expectedTitleCaseMinorWord: "Expected \"{{actual}}\" to be \"{{expected}}\" (Title Case - minor word).",
854
+ expectedSentenceCase: "Expected \"{{actual}}\" to be \"{{expected}}\" (Sentence case).",
855
+ expectedPreserveWord: "Expected \"{{actual}}\" to be \"{{expected}}\" (preserved word)."
856
+ }
857
+ },
858
+ create(context) {
859
+ const sourceCode = context.sourceCode;
860
+ const caseStyle = context.options[0]?.style || "Title Case";
861
+ const { preserveWords, preservePhrases } = parsePreserveWords(context.options[0]?.preserveWords || defaultPreserveWords);
862
+ const minorWords = context.options[0]?.minorWords || defaultMinorWords;
863
+ const ignorePatterns = (context.options[0]?.ignorePatterns || [
864
+ "/^v\\d+/u",
865
+ "/\\w+\\.[a-z\\d]+$/u",
866
+ "/\\w*(?:API|Api)$/u",
867
+ "/\\w*(?:SDK|Sdk)$/u",
868
+ "/\\w*(?:CLI|Cli)$/u"
869
+ ]).map((pattern) => {
870
+ if (isRegExp(pattern)) return toRegExp(pattern);
871
+ try {
872
+ return new RegExp(pattern, "v");
873
+ } catch {}
874
+ try {
875
+ return new RegExp(pattern, "u");
876
+ } catch {}
877
+ return new RegExp(pattern);
878
+ });
879
+ /**
880
+ * Check text node and report word-level errors
881
+ */
882
+ function checkTextNode(node, firstNode, lastNode) {
883
+ const text = sourceCode.getText(node);
884
+ const wordAndOffsets = parseText(text, firstNode, lastNode);
885
+ const processed = /* @__PURE__ */ new Set();
886
+ for (let index = 0; index < wordAndOffsets.length; index++) {
887
+ if (processed.has(index)) continue;
888
+ processed.add(index);
889
+ const wordAndOffset = wordAndOffsets[index];
890
+ if (wordAndOffset.punctuation) continue;
891
+ if (ignorePatterns.some((pattern) => pattern.test(wordAndOffset.word))) continue;
892
+ const preservePhrase = findPreservePhrase(wordAndOffsets, index);
893
+ if (preservePhrase) {
894
+ for (let wordIndex = 0; wordIndex < preservePhrase.length; wordIndex++) {
895
+ processed.add(index + wordIndex);
896
+ verifyWord(wordAndOffsets[index + wordIndex], preservePhrase[wordIndex], "preserved");
897
+ }
898
+ continue;
899
+ }
900
+ const preserveWordList = preserveWords.get(wordAndOffset.word.toLowerCase());
901
+ if (preserveWordList) {
902
+ if (!preserveWordList.some((w) => w === wordAndOffset.word)) verifyWord(wordAndOffset, preserveWordList[0], "preserved");
903
+ continue;
904
+ }
905
+ const expectedWordResult = convertWord(wordAndOffset, caseStyle, minorWords);
906
+ verifyWord(wordAndOffset, expectedWordResult.word, expectedWordResult.isMinorWord ? "minor" : "normal");
907
+ }
908
+ /**
909
+ * Verify a single word against the expected casing
910
+ */
911
+ function verifyWord(wordAndOffset, expectedWord, wordType = "normal") {
912
+ const { word, offset } = wordAndOffset;
913
+ if (word === expectedWord) return;
914
+ const nodeLoc = sourceCode.getLoc(node);
915
+ const beforeLines = text.slice(0, offset).split(/\n/u);
916
+ const line = nodeLoc.start.line + beforeLines.length - 1;
917
+ const column = (beforeLines.length === 1 ? nodeLoc.start.column : 1) + (beforeLines.at(-1) || "").length;
918
+ const nodeRange = sourceCode.getRange(node);
919
+ const wordRange = [nodeRange[0] + offset, nodeRange[0] + offset + word.length];
920
+ context.report({
921
+ node,
922
+ messageId: wordType === "preserved" ? "expectedPreserveWord" : caseStyle === "Title Case" ? wordType === "minor" ? "expectedTitleCaseMinorWord" : "expectedTitleCase" : "expectedSentenceCase",
923
+ data: {
924
+ actual: word,
925
+ expected: expectedWord
926
+ },
927
+ loc: {
928
+ start: {
929
+ line,
930
+ column
931
+ },
932
+ end: {
933
+ line,
934
+ column: column + word.length
935
+ }
936
+ },
937
+ fix(fixer) {
938
+ return fixer.replaceTextRange(wordRange, expectedWord);
939
+ }
940
+ });
941
+ }
942
+ }
943
+ /**
944
+ * Find a preserve phrase starting from the given index
945
+ * Returns the longest matching phrase or null if no match is found
946
+ */
947
+ function findPreservePhrase(wordAndOffsets, index) {
948
+ const firstWord = wordAndOffsets[index];
949
+ if (firstWord.punctuation) return null;
950
+ const firstLowerWord = firstWord.word.toLowerCase();
951
+ let returnCandidate = null;
952
+ let subWords = null;
953
+ for (const phrase of preservePhrases) {
954
+ if (returnCandidate && returnCandidate.preservePhrase.length !== phrase.length) break;
955
+ if (firstLowerWord !== phrase[0].toLowerCase()) continue;
956
+ if (!subWords || subWords.length !== phrase.length) subWords = wordAndOffsets.slice(index, index + phrase.length).map((wo) => wo.word);
957
+ if (subWords.length === phrase.length && subWords.every((word, i) => word.toLowerCase() === phrase[i].toLowerCase())) {
958
+ let matchCount = 0;
959
+ for (let i = 0; i < subWords.length; i++) {
960
+ const word = subWords[i];
961
+ if (word === phrase[i]) matchCount++;
962
+ }
963
+ if (!returnCandidate || matchCount > returnCandidate.matchCount) returnCandidate = {
964
+ preservePhrase: phrase,
965
+ matchCount
966
+ };
967
+ }
968
+ }
969
+ return returnCandidate?.preservePhrase ?? null;
970
+ }
971
+ return { heading(node) {
972
+ if (!node.children.length) return;
973
+ const children = node.children.filter((child) => child.type !== "text" || child.value.trim());
974
+ children.forEach((child, i) => {
975
+ if (child.type === "text") checkTextNode(child, i === 0, i === node.children.length - 1);
976
+ });
977
+ } };
978
+ }
979
+ });
980
+
114
981
  //#endregion
115
982
  //#region src/rules/no-text-backslash-linebreak.ts
116
983
  var no_text_backslash_linebreak_default = createRule("no-text-backslash-linebreak", {
@@ -749,31 +1616,6 @@ var prefer_linked_words_default = createRule("prefer-linked-words", {
749
1616
  }
750
1617
  });
751
1618
 
752
- //#endregion
753
- //#region src/utils/regexp.ts
754
- const RE_REGEXP_STR = /^\/(.+)\/([A-Za-z]*)$/u;
755
- /**
756
- * Convert a string to the `RegExp`.
757
- * Normal strings (e.g. `"foo"`) is converted to `/^foo$/` of `RegExp`.
758
- * Strings like `"/^foo/i"` are converted to `/^foo/i` of `RegExp`.
759
- *
760
- * @param {string} string The string to convert.
761
- * @returns {RegExp} Returns the `RegExp`.
762
- */
763
- function toRegExp(string) {
764
- const parts = RE_REGEXP_STR.exec(string);
765
- if (parts) return new RegExp(parts[1], parts[2]);
766
- return { test: (s) => s === string };
767
- }
768
- /**
769
- * Checks whether given string is regexp string
770
- * @param {string} string
771
- * @returns {boolean}
772
- */
773
- function isRegExp(string) {
774
- return Boolean(RE_REGEXP_STR.test(string));
775
- }
776
-
777
1619
  //#endregion
778
1620
  //#region src/rules/sort-definitions.ts
779
1621
  var sort_definitions_default = createRule("sort-definitions", {
@@ -1060,8 +1902,10 @@ function normalizedURL(url) {
1060
1902
  //#endregion
1061
1903
  //#region src/utils/rules.ts
1062
1904
  const rules$1 = [
1905
+ canonical_code_block_language_default,
1063
1906
  definitions_last_default,
1064
1907
  hard_linebreak_style_default,
1908
+ heading_casing_default,
1065
1909
  no_text_backslash_linebreak_default,
1066
1910
  no_trailing_spaces_default,
1067
1911
  prefer_inline_code_words_default,
@@ -1102,7 +1946,7 @@ __export(meta_exports, {
1102
1946
  version: () => version
1103
1947
  });
1104
1948
  const name = "eslint-plugin-markdown-preferences";
1105
- const version = "0.8.0";
1949
+ const version = "0.9.0";
1106
1950
 
1107
1951
  //#endregion
1108
1952
  //#region src/index.ts
@@ -1111,11 +1955,16 @@ const rules = rules$1.reduce((obj, r) => {
1111
1955
  obj[r.meta.docs.ruleName] = r;
1112
1956
  return obj;
1113
1957
  }, {});
1958
+ const resources = {
1959
+ defaultPreserveWords,
1960
+ defaultMinorWords
1961
+ };
1114
1962
  var src_default = {
1115
1963
  meta: meta_exports,
1116
1964
  configs,
1117
- rules
1965
+ rules,
1966
+ resources
1118
1967
  };
1119
1968
 
1120
1969
  //#endregion
1121
- export { configs, src_default as default, meta_exports as meta, rules };
1970
+ export { configs, src_default as default, meta_exports as meta, resources, rules };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-markdown-preferences",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "ESLint plugin that enforces our markdown preferences",
5
5
  "type": "module",
6
6
  "exports": {