eslint-plugin-mso-conditionals 1.0.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 ADDED
@@ -0,0 +1,88 @@
1
+ # eslint-plugin-mso-conditionals
2
+
3
+ ESLint plugin for validating **MSO (Outlook) conditional comments** in HTML email.
4
+
5
+ Extracts `<!--[if mso]>` … `<![endif]-->` blocks from HTML files and lints them for valid syntax and matched pairs using a custom processor and AST parser.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install eslint-plugin-mso-conditionals --save-dev
11
+ ```
12
+
13
+ Requires ESLint 9+ (flat config) and Node.js 18+.
14
+
15
+ ## Quick Start
16
+
17
+ ```js
18
+ // eslint.config.js
19
+ import msoConditionals from 'eslint-plugin-mso-conditionals';
20
+
21
+ export default [
22
+ ...msoConditionals.configs.recommended,
23
+ ];
24
+ ```
25
+
26
+ This lints MSO conditional comments inside `**/*.html`, `**/*.amp`, and `**/*.ampscript` files.
27
+
28
+ ## VS Code Setup
29
+
30
+ To see `eslint(mso-conditionals/...)` diagnostics inline in VS Code, the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) must be configured to validate HTML files:
31
+
32
+ ```json
33
+ {
34
+ "eslint.validate": ["html", "javascript"]
35
+ }
36
+ ```
37
+
38
+ The [MSO Conditional Comments](https://marketplace.visualstudio.com/items?itemName=joernberkefeld.mso-conditionals) VS Code extension provides the same diagnostics without requiring ESLint. Use this plugin when you want linting in CI or in editors other than VS Code.
39
+
40
+ ## Rules
41
+
42
+ | Rule | Default | Description |
43
+ |---|---|---|
44
+ | [`mso-conditionals/valid-mso-condition`](docs/rules/valid-mso-condition.md) | `error` | Validate MSO condition syntax — correct keyword, operator, and version |
45
+ | [`mso-conditionals/matching-mso-endif`](docs/rules/matching-mso-endif.md) | `error` | Ensure every opener has a matching `[endif]` and vice versa |
46
+
47
+ ## Processor
48
+
49
+ | Processor | Files | Purpose |
50
+ |---|---|---|
51
+ | `mso-conditionals/html` | `**/*.html`, `**/*.amp`, `**/*.ampscript` | Extracts MSO conditional comment strings for linting |
52
+
53
+ The processor emits a virtual `.mso` file per source file containing one MSO comment per line, preserving line offsets so reported locations map back to the original file.
54
+
55
+ ## Config
56
+
57
+ ### `msoConditionals.configs.recommended`
58
+
59
+ An array of two flat config objects:
60
+
61
+ 1. **Processor config** — registers `mso-conditionals/html` on HTML/AMP files.
62
+ 2. **Rules config** — applies `valid-mso-condition` and `matching-mso-endif` at `error` severity on the extracted `.mso` virtual files.
63
+
64
+ Spread it in your config:
65
+
66
+ ```js
67
+ export default [
68
+ ...msoConditionals.configs.recommended,
69
+ // your other rules...
70
+ ];
71
+ ```
72
+
73
+ To adjust severity, override individual rules after spreading:
74
+
75
+ ```js
76
+ export default [
77
+ ...msoConditionals.configs.recommended,
78
+ {
79
+ rules: {
80
+ 'mso-conditionals/matching-mso-endif': 'warn',
81
+ },
82
+ },
83
+ ];
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "eslint-plugin-mso-conditionals",
3
+ "version": "1.0.0",
4
+ "description": "ESLint plugin for validating MSO (Outlook) conditional comments in HTML email",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "LICENSE"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/JoernBerkefeld/eslint-plugin-mso-conditionals.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/JoernBerkefeld/eslint-plugin-mso-conditionals/issues"
20
+ },
21
+ "homepage": "https://github.com/JoernBerkefeld/eslint-plugin-mso-conditionals#readme",
22
+ "author": "Joern Berkefeld",
23
+ "license": "MIT",
24
+ "keywords": [
25
+ "eslint",
26
+ "eslintplugin",
27
+ "mso",
28
+ "outlook",
29
+ "email",
30
+ "conditional-comments",
31
+ "html-email"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "peerDependencies": {
37
+ "eslint": ">=9.0.0"
38
+ },
39
+ "dependencies": {
40
+ "mso-conditional-parser": "^1.0.0"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test tests/rules.test.mjs",
44
+ "lint": "eslint src tests",
45
+ "lint:fix": "eslint --fix src tests",
46
+ "prepare": "husky || true",
47
+ "format": "prettier --write .",
48
+ "format:check": "prettier --check ."
49
+ },
50
+ "devDependencies": {
51
+ "@eslint/js": "^10.0.1",
52
+ "eslint": "^10.1.0",
53
+ "eslint-config-prettier": "^10.1.8",
54
+ "eslint-plugin-jsdoc": "^62.0.0",
55
+ "eslint-plugin-prettier": "^5.5.0",
56
+ "eslint-plugin-unicorn": "^64.0.0",
57
+ "globals": "^17.4.0",
58
+ "husky": "^9.1.7",
59
+ "lint-staged": "^17.0.5",
60
+ "prettier": "^3.8.2"
61
+ },
62
+ "lint-staged": {
63
+ "*.{js,mjs,cjs}": [
64
+ "eslint --fix"
65
+ ]
66
+ },
67
+ "volta": {
68
+ "node": "22.15.0"
69
+ }
70
+ }
package/src/index.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * eslint-plugin-mso-conditionals
3
+ *
4
+ * ESLint plugin for validating MSO (Outlook) conditional comments in HTML email.
5
+ * Provides a processor that extracts MSO comments from HTML and a custom parser
6
+ * that turns them into an AST, enabling rules to validate their syntax and structure.
7
+ */
8
+
9
+ import msoEslintParser from './mso-eslint-parser.js';
10
+ import processor from './processor.js';
11
+ import validMsoCondition from './rules/valid-mso-condition.js';
12
+ import matchingMsoEndif from './rules/matching-mso-endif.js';
13
+
14
+ const plugin = {
15
+ meta: {
16
+ name: 'eslint-plugin-mso-conditionals',
17
+ version: '1.0.0',
18
+ },
19
+
20
+ rules: {
21
+ 'valid-mso-condition': validMsoCondition,
22
+ 'matching-mso-endif': matchingMsoEndif,
23
+ },
24
+
25
+ processors: {
26
+ html: processor,
27
+ },
28
+
29
+ configs: {},
30
+ };
31
+
32
+ Object.assign(plugin.configs, {
33
+ /**
34
+ * Recommended config for HTML files containing MSO conditional comments.
35
+ * Returns an array of two config objects (processor + rules), following the
36
+ * same flat-config pattern as eslint-plugin-sfmc's `configs.embedded`.
37
+ */
38
+ recommended: [
39
+ {
40
+ name: 'mso-conditionals/html-processor',
41
+ plugins: { 'mso-conditionals': plugin },
42
+ files: ['**/*.html', '**/*.amp', '**/*.ampscript'],
43
+ processor: 'mso-conditionals/html',
44
+ },
45
+ {
46
+ name: 'mso-conditionals/mso-rules',
47
+ plugins: { 'mso-conditionals': plugin },
48
+ files: ['**/*.html/*.mso', '**/*.amp/*.mso', '**/*.ampscript/*.mso'],
49
+ languageOptions: { parser: msoEslintParser },
50
+ rules: {
51
+ 'mso-conditionals/valid-mso-condition': 'error',
52
+ 'mso-conditionals/matching-mso-endif': 'error',
53
+ },
54
+ },
55
+ ],
56
+ });
57
+
58
+ export default plugin;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ESLint-compatible parser for virtual `.mso` files produced by the processor.
3
+ *
4
+ * Receives a text where each non-empty line contains one raw MSO comment
5
+ * string (opener or closer). Produces a minimal ESLint `Program` AST with
6
+ * one `MsoComment` node per line, carrying both the raw text and the parsed
7
+ * result from `mso-conditional-parser`.
8
+ *
9
+ * Exports `visitorKeys` so ESLint's traversal engine knows the tree shape.
10
+ */
11
+
12
+ import { parseMsoComment, parseMsoEndComment } from 'mso-conditional-parser';
13
+
14
+ /**
15
+ * Visitor keys for all node types in the MSO AST.
16
+ * MsoComment has no child nodes — it is a leaf.
17
+ *
18
+ * @type {Record<string, string[]>}
19
+ */
20
+ export const visitorKeys = {
21
+ Program: ['body'],
22
+ MsoComment: [],
23
+ };
24
+
25
+ /**
26
+ * Builds a line/column offset table for the given text.
27
+ *
28
+ * @param {string} text - Source text.
29
+ * @returns {number[]} Array where index i holds the character offset of line i.
30
+ */
31
+ function buildLineStarts(text) {
32
+ const starts = [0];
33
+ for (const match of text.matchAll(/\n/g)) {
34
+ starts.push(match.index + 1);
35
+ }
36
+
37
+ return starts;
38
+ }
39
+
40
+ /**
41
+ * Parses a virtual `.mso` file into an ESLint-compatible Program AST.
42
+ *
43
+ * @param {string} text - Text of the virtual `.mso` file.
44
+ * @param {{ loc?: boolean, range?: boolean, tokens?: boolean, comment?: boolean }} _options - ESLint parse options (unused but required by interface).
45
+ * @returns {{ type: 'Program', body: object[], tokens: [], comments: [], range: number[], loc: object, visitorKeys: Record<string, string[]> }} ESLint-compatible Program AST.
46
+ */
47
+ export function parse(text, _options) {
48
+ const lineStarts = buildLineStarts(text);
49
+ const lines = text.split('\n');
50
+ const body = [];
51
+
52
+ for (const [lineIndex, line] of lines.entries()) {
53
+ const raw = line.trim();
54
+ if (!raw) {
55
+ continue;
56
+ }
57
+
58
+ const lineStart = lineStarts[lineIndex] ?? text.length;
59
+ const start = lineStart;
60
+ const end = lineStart + line.length;
61
+
62
+ const parsed = parseMsoComment(raw) ?? parseMsoEndComment(raw);
63
+
64
+ body.push({
65
+ type: 'MsoComment',
66
+ raw,
67
+ parsed,
68
+ range: [start, end],
69
+ loc: {
70
+ start: { line: lineIndex + 1, column: 0 },
71
+ end: { line: lineIndex + 1, column: line.length },
72
+ },
73
+ });
74
+ }
75
+
76
+ const programEnd = text.length;
77
+
78
+ return {
79
+ type: 'Program',
80
+ body,
81
+ tokens: [],
82
+ comments: [],
83
+ range: [0, programEnd],
84
+ loc: {
85
+ start: { line: 1, column: 0 },
86
+ end: { line: lines.length, column: 0 },
87
+ },
88
+ visitorKeys,
89
+ };
90
+ }
91
+
92
+ export default { parse, visitorKeys };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ESLint processor that extracts MSO conditional comments from HTML files.
3
+ *
4
+ * Scans HTML for all MSO comment patterns (openers and closers) and
5
+ * extracts them into a single virtual `.mso` file. Padding newlines
6
+ * preserve original line numbers so ESLint reports errors at the correct
7
+ * positions in the source file.
8
+ *
9
+ * Pattern overview:
10
+ * Openers: <!--[if …]> <!--[if …]><!-- <![if …]>
11
+ * Closers: <![endif]--> <!--<![endif]--> <![endif]>
12
+ */
13
+
14
+ /**
15
+ * Regex that matches any MSO comment (opener or closer) on a single line.
16
+ * Named capture groups:
17
+ * comment — the full raw comment string
18
+ */
19
+ const MSO_COMMENT_RE =
20
+ /<!--\[if\s[^\]]*\]>(?:<!--)?|<!\[if\s[^\]]*\]>|(?:<!--)?<!\[endif\]-->|<!\[endif\]>/g;
21
+
22
+ /**
23
+ * Counts newline characters before a given position in text.
24
+ *
25
+ * @param {string} text - The source text.
26
+ * @param {number} pos - The character offset.
27
+ * @returns {number} Number of newlines before pos.
28
+ */
29
+ function countNewlinesBefore(text, pos) {
30
+ let count = 0;
31
+ for (let index = 0; index < pos; index++) {
32
+ if (text[index] === '\n') {
33
+ count++;
34
+ }
35
+ }
36
+
37
+ return count;
38
+ }
39
+
40
+ /**
41
+ * Preprocesses an HTML file by extracting all MSO comments into a single
42
+ * virtual `.mso` file, preserving line offsets.
43
+ *
44
+ * @param {string} text - Full source text of the HTML file.
45
+ * @param {string} filename - Original filename.
46
+ * @returns {{text: string, filename: string}[]} Array of virtual file objects.
47
+ */
48
+ export function preprocess(text, filename) {
49
+ const lines = [];
50
+ let maxLine = 0;
51
+
52
+ MSO_COMMENT_RE.lastIndex = 0;
53
+ let match;
54
+ while ((match = MSO_COMMENT_RE.exec(text)) !== null) {
55
+ const line = countNewlinesBefore(text, match.index);
56
+ lines.push({ line, text: match[0] });
57
+ if (line > maxLine) {
58
+ maxLine = line;
59
+ }
60
+ }
61
+
62
+ if (lines.length === 0) {
63
+ return [text];
64
+ }
65
+
66
+ // Build a single virtual file: place each comment at its original line number
67
+ // using padding newlines so that column/line errors map back correctly.
68
+ const rows = Array.from({ length: maxLine + 1 }, () => '');
69
+ for (const entry of lines) {
70
+ rows[entry.line] = entry.text;
71
+ }
72
+
73
+ return [{ text: rows.join('\n'), filename: `${filename}/mso-comments.mso` }];
74
+ }
75
+
76
+ /**
77
+ * Postprocesses messages from linting the virtual `.mso` file.
78
+ *
79
+ * @param {import('eslint').Linter.LintMessage[][]} messages - Nested message arrays.
80
+ * @returns {import('eslint').Linter.LintMessage[]} Flat array of lint messages.
81
+ */
82
+ export function postprocess(messages) {
83
+ return messages.flat();
84
+ }
85
+
86
+ export default { preprocess, postprocess, supportsAutofix: false };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Rule: matching-mso-endif
3
+ *
4
+ * Validates that every MSO conditional comment opener has a matching
5
+ * closer ([endif]) and that no [endif] appears without an opening comment.
6
+ *
7
+ * Uses a stack to track nested conditionals across the entire virtual
8
+ * .mso file (one file per source HTML file). Reports unclosed openers
9
+ * at Program:exit.
10
+ */
11
+
12
+ export default {
13
+ meta: {
14
+ type: 'problem',
15
+ docs: {
16
+ description:
17
+ 'Ensure every MSO conditional comment opener has a matching closing [endif]',
18
+ recommended: true,
19
+ },
20
+ messages: {
21
+ unclosedConditional: 'MSO conditional "{{raw}}" is never closed with [endif]',
22
+ unmatchedEndif: 'Found [endif] with no matching MSO conditional opener',
23
+ },
24
+ schema: [],
25
+ },
26
+
27
+ create(context) {
28
+ /** @type {object[]} */
29
+ const stack = [];
30
+
31
+ return {
32
+ MsoComment(node) {
33
+ if (!node.parsed) {
34
+ return;
35
+ }
36
+
37
+ if (node.parsed.isClosing) {
38
+ if (stack.length === 0) {
39
+ context.report({ node, messageId: 'unmatchedEndif' });
40
+ } else {
41
+ stack.pop();
42
+ }
43
+ } else {
44
+ // opener
45
+ stack.push(node);
46
+ }
47
+ },
48
+
49
+ 'Program:exit'() {
50
+ for (const opener of stack) {
51
+ context.report({
52
+ node: opener,
53
+ messageId: 'unclosedConditional',
54
+ data: { raw: opener.raw },
55
+ });
56
+ }
57
+ },
58
+ };
59
+ },
60
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Rule: valid-mso-condition
3
+ *
4
+ * Validates that every MSO conditional comment opener uses correct syntax:
5
+ * - known keyword (mso, not mos)
6
+ * - valid comparison operator (gte, gt, lte, lt, eq)
7
+ * - valid version number (9, 10, 11, 12, 14, 15, 16 — no 13)
8
+ * - operator has a version number when required
9
+ */
10
+
11
+ export default {
12
+ meta: {
13
+ type: 'problem',
14
+ docs: {
15
+ description:
16
+ 'Ensure MSO conditional comments use valid syntax (correct keyword, operator, and version number)',
17
+ recommended: true,
18
+ },
19
+ messages: {
20
+ invalidCondition: 'Invalid MSO conditional comment: {{error}}. Found: "{{raw}}"',
21
+ },
22
+ schema: [],
23
+ },
24
+
25
+ create(context) {
26
+ return {
27
+ MsoComment(node) {
28
+ if (!node.parsed) {
29
+ return;
30
+ }
31
+
32
+ // Closers have no condition to validate
33
+ if (node.parsed.isClosing) {
34
+ return;
35
+ }
36
+
37
+ if (!node.parsed.isValid) {
38
+ context.report({
39
+ node,
40
+ messageId: 'invalidCondition',
41
+ data: {
42
+ error: node.parsed.error ?? 'unknown error',
43
+ raw: node.raw,
44
+ },
45
+ });
46
+ }
47
+ },
48
+ };
49
+ },
50
+ };