@salesforcedevs/sfdocs-liquid-lint-filtered-blocks 1.0.1-alpha.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # liquid-lint-filtered-blocks
2
+
3
+ This remark-lint plugin validates structural issues in Liquid filter blocks
4
+ (`{% englishonly %}` / `{% endenglishonly %}`, `{% future %}` / `{% endfuture %}`,
5
+ etc.) within sfdocs Markdown files.
6
+
7
+ ## What it checks
8
+
9
+ - **Redundant nesting**: a filter block inside another block of the same type is
10
+ redundant and likely a copy-paste mistake.
11
+ - **Entire file wrapped**: when all non-frontmatter content is inside a single
12
+ filter block, the topic should use `filters: [<tag>]` in the TOC instead.
13
+ - **Empty headings after removal**: headings outside a filter block whose entire
14
+ section body is inside that block would render as empty headings in filtered
15
+ builds.
16
+
17
+ ## Options
18
+
19
+ ```ts
20
+ {
21
+ filterTags?: string[]
22
+ }
23
+ ```
24
+
25
+ An array of Liquid filter tag names to validate (e.g. `["englishonly", "future"]`).
26
+ Each tag is checked independently.
27
+
28
+ ## Install & build
29
+
30
+ ```
31
+ yarn install && yarn build
32
+ ```
33
+
34
+ >> Release versioning and commits are automated via Lerna when you merge the
35
+ >> release PR.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAsJA,kBAA2C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AA8GA,kBAA2C"}
package/dist/index.js CHANGED
@@ -3,43 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  const unified_lint_rule_1 = __importDefault(require("unified-lint-rule"));
6
+ const unist_util_visit_1 = __importDefault(require("unist-util-visit"));
6
7
  const sfdocs_liquid_lint_utils_1 = require("@salesforcedevs/sfdocs-liquid-lint-utils");
7
8
  const SOURCE = 'sfdocs-liquid-lint:liquid-filtered-blocks';
8
- function findBlockRanges(content, tagName) {
9
- const ranges = [];
10
- const tagRe = new RegExp(`{%-?\\s*(${tagName}|end${tagName})\\s*-?%}`, 'g');
11
- const openStack = [];
12
- let match;
13
- while ((match = tagRe.exec(content)) !== null) {
14
- if (match[1] === tagName) {
15
- openStack.push({ index: match.index, end: match.index + match[0].length });
16
- }
17
- else if (openStack.length > 0) {
18
- const open = openStack.pop();
19
- ranges.push({
20
- openStart: open.index,
21
- openEnd: open.end,
22
- closeStart: match.index,
23
- closeEnd: match.index + match[0].length,
24
- });
25
- }
26
- }
27
- return ranges;
28
- }
29
9
  function isInsideRange(offset, ranges) {
30
10
  return ranges.some(r => offset >= r.openStart && offset < r.closeEnd);
31
11
  }
32
- function isNestedBlock(blockIndex, ranges) {
33
- const block = ranges[blockIndex];
34
- for (let i = 0; i < ranges.length; i++) {
35
- if (i === blockIndex)
36
- continue;
37
- const outer = ranges[i];
38
- if (block.openStart > outer.openStart && block.closeEnd < outer.closeEnd) {
39
- return true;
40
- }
41
- }
42
- return false;
12
+ function isNestedBlock(block, ranges) {
13
+ return ranges.some(r => r !== block && block.openStart > r.openStart && block.closeEnd < r.closeEnd);
43
14
  }
44
15
  function isEntireFileWrapped(content, ranges) {
45
16
  if (ranges.length !== 1)
@@ -49,30 +20,27 @@ function isEntireFileWrapped(content, ranges) {
49
20
  const after = content.slice(block.closeEnd);
50
21
  return !/\S/.test(before) && !/\S/.test(after);
51
22
  }
52
- function findEmptySections(content, ranges) {
53
- const lines = content.split('\n');
54
- const headingPositions = [];
55
- let offset = 0;
56
- for (const line of lines) {
57
- if (/^#{1,6}\s+\S/.test(line) && !isInsideRange(offset, ranges)) {
58
- headingPositions.push(offset);
23
+ function findEmptyHeadings(tree, content, ranges) {
24
+ var _a, _b;
25
+ const headings = [];
26
+ (0, unist_util_visit_1.default)(tree, 'heading', (node) => {
27
+ var _a, _b;
28
+ const offset = (_b = (_a = node.position) === null || _a === void 0 ? void 0 : _a.start) === null || _b === void 0 ? void 0 : _b.offset;
29
+ if (offset !== undefined && !isInsideRange(offset, ranges)) {
30
+ headings.push(node);
59
31
  }
60
- offset += line.length + 1;
61
- }
62
- const emptyHeadingOffsets = [];
63
- for (let i = 0; i < headingPositions.length; i++) {
64
- const sectionStart = headingPositions[i];
65
- const sectionEnd = i + 1 < headingPositions.length
66
- ? headingPositions[i + 1]
67
- : content.length;
68
- const headingLine = content.slice(sectionStart, content.indexOf('\n', sectionStart));
69
- const afterHeading = content.slice(sectionStart + headingLine.length, sectionEnd);
70
- const stripped = stripFilteredBlocks(afterHeading, ranges, sectionStart + headingLine.length);
32
+ });
33
+ const emptyHeadings = [];
34
+ for (let i = 0; i < headings.length; i++) {
35
+ const sectionStart = headings[i].position.end.offset;
36
+ const sectionEnd = (_b = (_a = headings[i + 1]) === null || _a === void 0 ? void 0 : _a.position.start.offset) !== null && _b !== void 0 ? _b : content.length;
37
+ const body = content.slice(sectionStart, sectionEnd);
38
+ const stripped = stripFilteredBlocks(body, ranges, sectionStart);
71
39
  if (!/\S/.test(stripped)) {
72
- emptyHeadingOffsets.push(sectionStart);
40
+ emptyHeadings.push(headings[i]);
73
41
  }
74
42
  }
75
- return emptyHeadingOffsets;
43
+ return emptyHeadings;
76
44
  }
77
45
  function stripFilteredBlocks(section, allRanges, sectionOffset) {
78
46
  let result = section;
@@ -86,36 +54,40 @@ function stripFilteredBlocks(section, allRanges, sectionOffset) {
86
54
  }
87
55
  return result;
88
56
  }
89
- function checkFilteredBlocks(_tree, file, option) {
57
+ function validateTag(tree, file, raw, content, tagName) {
58
+ const ranges = (0, sfdocs_liquid_lint_utils_1.findBlockRanges)(content, tagName);
59
+ if (ranges.length === 0)
60
+ return;
61
+ for (const range of ranges) {
62
+ if (isNestedBlock(range, ranges)) {
63
+ if (tagName === 'englishonly') {
64
+ file.message(`Redundant nested englishonly block — already inside an englishonly block.`, (0, sfdocs_liquid_lint_utils_1.offsetToPosition)(raw, range.openStart));
65
+ }
66
+ else {
67
+ file.message(`Redundant nested ${tagName} block — already inside a ${tagName} block.`, (0, sfdocs_liquid_lint_utils_1.offsetToPosition)(raw, range.openStart));
68
+ }
69
+ }
70
+ }
71
+ if (isEntireFileWrapped(content, ranges)) {
72
+ file.message(`All content is inside ${tagName} block — use filters: [${tagName}] in TOC instead.`, (0, sfdocs_liquid_lint_utils_1.offsetToPosition)(raw, ranges[0].openStart));
73
+ }
74
+ for (const heading of findEmptyHeadings(tree, content, ranges)) {
75
+ file.message(`Heading will have no content when the ${tagName} block is filtered out. Remove or adjust the heading.`, heading);
76
+ }
77
+ }
78
+ function checkFilteredBlocks(tree, file, option) {
90
79
  var _a;
91
- if (!file.path)
80
+ const filterTags = option === null || option === void 0 ? void 0 : option.filterTags;
81
+ if (!file.path || !filterTags || filterTags.length === 0)
92
82
  return;
93
83
  const raw = String((_a = file.contents) !== null && _a !== void 0 ? _a : '');
94
84
  if (!raw)
95
85
  return;
96
- const filterTags = option === null || option === void 0 ? void 0 : option.filterTags;
97
- if (!filterTags || filterTags.length === 0)
86
+ const parsed = (0, sfdocs_liquid_lint_utils_1.parseFrontmatter)(raw);
87
+ if (!parsed)
98
88
  return;
99
- const content = (0, sfdocs_liquid_lint_utils_1.protectCodeBlocks)(raw);
100
89
  for (const tagName of filterTags) {
101
- const ranges = findBlockRanges(content, tagName);
102
- if (ranges.length === 0)
103
- continue;
104
- for (let i = 0; i < ranges.length; i++) {
105
- if (isNestedBlock(i, ranges)) {
106
- const position = (0, sfdocs_liquid_lint_utils_1.offsetToPosition)(raw, ranges[i].openStart);
107
- file.message(`Redundant nested ${tagName} block — already inside a ${tagName} block.`, position);
108
- }
109
- }
110
- if (isEntireFileWrapped(content, ranges)) {
111
- const position = (0, sfdocs_liquid_lint_utils_1.offsetToPosition)(raw, ranges[0].openStart);
112
- file.message(`All content is inside ${tagName} block — use \`filters: [${tagName}]\` in TOC instead.`, position);
113
- }
114
- const emptyHeadingOffsets = findEmptySections(content, ranges);
115
- for (const offset of emptyHeadingOffsets) {
116
- const position = (0, sfdocs_liquid_lint_utils_1.offsetToPosition)(raw, offset);
117
- file.message(`Heading would have no content after ${tagName} block is removed.`, position);
118
- }
90
+ validateTag(tree, file, raw, parsed.content, tagName);
119
91
  }
120
92
  }
121
93
  module.exports = (0, unified_lint_rule_1.default)(SOURCE, checkFilteredBlocks);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;AAAA,0EAAqC;AAErC,uFAA+F;AAE/F,MAAM,MAAM,GAAG,2CAA2C,CAAC;AAa3D,SAAS,eAAe,CAAC,OAAe,EAAE,OAAe;IACrD,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,YAAY,OAAO,OAAO,OAAO,WAAW,EAAE,GAAG,CAAC,CAAC;IAC5E,MAAM,SAAS,GAAqC,EAAE,CAAC;IAEvD,IAAI,KAA6B,CAAC;IAClC,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,KAAK,IAAI,EAAE;QAC3C,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO,EAAE;YACtB,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,GAAG,EAAE,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;SAC9E;aAAM,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE;YAC7B,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,EAAG,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC;gBACR,SAAS,EAAE,IAAI,CAAC,KAAK;gBACrB,OAAO,EAAE,IAAI,CAAC,GAAG;gBACjB,UAAU,EAAE,KAAK,CAAC,KAAK;gBACvB,QAAQ,EAAE,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM;aAC1C,CAAC,CAAC;SACN;KACJ;IACD,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,SAAS,aAAa,CAAC,MAAc,EAAE,MAAoB;IACvD,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,CAAC,SAAS,IAAI,MAAM,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,aAAa,CAAC,UAAkB,EAAE,MAAoB;IAC3D,MAAM,KAAK,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IACjC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QACpC,IAAI,CAAC,KAAK,UAAU;YAAE,SAAS;QAC/B,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACxB,IAAI,KAAK,CAAC,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,EAAE;YACtE,OAAO,IAAI,CAAC;SACf;KACJ;IACD,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,SAAS,mBAAmB,CAAC,OAAe,EAAE,MAAoB;IAC9D,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACtC,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACxB,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IACjD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC5C,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACnD,CAAC;AAED,SAAS,iBAAiB,CAAC,OAAe,EAAE,MAAoB;IAC5D,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAClC,MAAM,gBAAgB,GAAa,EAAE,CAAC;IACtC,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE;QACtB,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;YAC7D,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;SACjC;QACD,MAAM,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;KAC7B;IAED,MAAM,mBAAmB,GAAa,EAAE,CAAC;IACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,gBAAgB,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QAC9C,MAAM,YAAY,GAAG,gBAAgB,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,UAAU,GAAG,CAAC,GAAG,CAAC,GAAG,gBAAgB,CAAC,MAAM;YAC9C,CAAC,CAAC,gBAAgB,CAAC,CAAC,GAAG,CAAC,CAAC;YACzB,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;QAErB,MAAM,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC,YAAY,EAAE,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC;QACrF,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QAElF,MAAM,QAAQ,GAAG,mBAAmB,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;QAC9F,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE;YACtB,mBAAmB,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;SAC1C;KACJ;IACD,OAAO,mBAAmB,CAAC;AAC/B,CAAC;AAED,SAAS,mBAAmB,CAAC,OAAe,EAAE,SAAuB,EAAE,aAAqB;IACxF,IAAI,MAAM,GAAG,OAAO,CAAC;IACrB,MAAM,QAAQ,GAAG,SAAS;SACrB,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,IAAI,aAAa,IAAI,CAAC,CAAC,QAAQ,IAAI,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC;SACzF,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC;IAE/C,KAAK,MAAM,KAAK,IAAI,QAAQ,EAAE;QAC1B,MAAM,UAAU,GAAG,KAAK,CAAC,SAAS,GAAG,aAAa,CAAC;QACnD,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,GAAG,aAAa,CAAC;QAChD,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;KACjE;IACD,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,SAAS,mBAAmB,CAAC,KAAc,EAAE,IAAW,EAAE,MAA8B;;IACpF,IAAI,CAAC,IAAI,CAAC,IAAI;QAAE,OAAO;IACvB,MAAM,GAAG,GAAG,MAAM,CAAC,MAAA,IAAI,CAAC,QAAQ,mCAAI,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,GAAG;QAAE,OAAO;IAEjB,MAAM,UAAU,GAAG,MAAM,aAAN,MAAM,uBAAN,MAAM,CAAE,UAAU,CAAC;IACtC,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAEnD,MAAM,OAAO,GAAG,IAAA,4CAAiB,EAAC,GAAG,CAAC,CAAC;IAEvC,KAAK,MAAM,OAAO,IAAI,UAAU,EAAE;QAC9B,MAAM,MAAM,GAAG,eAAe,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACjD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QAElC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;YACpC,IAAI,aAAa,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE;gBAC1B,MAAM,QAAQ,GAAG,IAAA,2CAAgB,EAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;gBAC5D,IAAI,CAAC,OAAO,CACR,oBAAoB,OAAO,6BAA6B,OAAO,SAAS,EACxE,QAAQ,CACX,CAAC;aACL;SACJ;QAED,IAAI,mBAAmB,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE;YACtC,MAAM,QAAQ,GAAG,IAAA,2CAAgB,EAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;YAC5D,IAAI,CAAC,OAAO,CACR,yBAAyB,OAAO,4BAA4B,OAAO,qBAAqB,EACxF,QAAQ,CACX,CAAC;SACL;QAED,MAAM,mBAAmB,GAAG,iBAAiB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC/D,KAAK,MAAM,MAAM,IAAI,mBAAmB,EAAE;YACtC,MAAM,QAAQ,GAAG,IAAA,2CAAgB,EAAC,GAAG,EAAE,MAAM,CAAC,CAAC;YAC/C,IAAI,CAAC,OAAO,CACR,uCAAuC,OAAO,oBAAoB,EAClE,QAAQ,CACX,CAAC;SACL;KACJ;AACL,CAAC;AAED,iBAAS,IAAA,2BAAI,EAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforcedevs/sfdocs-liquid-lint-filtered-blocks",
3
- "version": "1.0.1-alpha.1",
3
+ "version": "1.0.1",
4
4
  "description": "Validates structural issues in liquid filter blocks (englishonly, future, etc.).",
5
5
  "author": "SFDocs Team",
6
6
  "main": "dist/index.js",
@@ -14,8 +14,9 @@
14
14
  },
15
15
  "license": "MIT",
16
16
  "dependencies": {
17
- "@salesforcedevs/sfdocs-liquid-lint-utils": "1.0.2-alpha.1",
17
+ "@salesforcedevs/sfdocs-liquid-lint-utils": "^1.0.2",
18
18
  "unified-lint-rule": "^1.0.6",
19
+ "unist-util-visit": "^2.0.3",
19
20
  "vfile": "^4.2.1"
20
21
  },
21
22
  "devDependencies": {
@@ -1,5 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import remark from 'remark';
3
+ import frontmatter from 'remark-frontmatter';
3
4
  import unified from 'unified';
4
5
  import vfile, { VFile } from 'vfile';
5
6
 
@@ -9,6 +10,7 @@ const PAGE_PATH = '/repo/content/en-us/project/guides/page.md';
9
10
 
10
11
  const run = (md: string, options?: any, p = PAGE_PATH): VFile =>
11
12
  remark()
13
+ .use(frontmatter)
12
14
  .use(plugin as unified.Attacher, options)
13
15
  .processSync(vfile({ path: p, contents: md }));
14
16
 
@@ -46,21 +48,6 @@ describe('sfdocs-lint:liquid-filtered-blocks', () => {
46
48
  expect(run(md, { filterTags: ['englishonly'] }).messages).toHaveLength(0);
47
49
  });
48
50
 
49
- test('no messages for valid future block', () => {
50
- const md = dedent`
51
- # Heading
52
-
53
- Regular content.
54
-
55
- {% future %}
56
- Future content here.
57
- {% endfuture %}
58
-
59
- More content after.
60
- `;
61
- expect(run(md, { filterTags: ['future'] }).messages).toHaveLength(0);
62
- });
63
-
64
51
  test('warns on nested same-tag blocks (englishonly)', () => {
65
52
  const md = dedent`
66
53
  {% englishonly %}
@@ -75,72 +62,32 @@ describe('sfdocs-lint:liquid-filtered-blocks', () => {
75
62
  const messages = run(md, { filterTags: ['englishonly'] }).messages;
76
63
  expect(messages).toHaveLength(1);
77
64
  expect(messages[0].message).toBe(
78
- 'Redundant nested englishonly block — already inside a englishonly block.',
65
+ 'Redundant nested englishonly block — already inside an englishonly block.',
79
66
  );
80
67
  expect(messages[0].source).toBe('sfdocs-liquid-lint');
81
68
  expect(messages[0].ruleId).toBe('liquid-filtered-blocks');
82
69
  });
83
70
 
84
- test('warns on nested same-tag blocks (future)', () => {
71
+ test('warns when entire file is wrapped', () => {
85
72
  const md = dedent`
86
- {% future %}
87
- Outer.
88
-
89
- {% future %}
90
- Inner.
91
- {% endfuture %}
73
+ {% englishonly %}
74
+ # Full Content
92
75
 
93
- {% endfuture %}
76
+ Everything here.
77
+ {% endenglishonly %}
94
78
  `;
95
- const messages = run(md, { filterTags: ['future'] }).messages;
79
+ const messages = run(md, { filterTags: ['englishonly'] }).messages;
96
80
  expect(messages).toHaveLength(1);
97
81
  expect(messages[0].message).toBe(
98
- 'Redundant nested future block — already inside a future block.',
82
+ 'All content is inside englishonly block — use filters: [englishonly] in TOC instead.',
99
83
  );
100
84
  });
101
85
 
102
- test('does NOT warn on cross-tag nesting (englishonly inside future)', () => {
103
- const md = dedent`
104
- # Heading
105
-
106
- Regular content.
107
-
108
- {% future %}
109
- Future content.
110
-
111
- {% englishonly %}
112
- English-only inside future.
113
- {% endenglishonly %}
114
-
115
- {% endfuture %}
116
-
117
- More content.
118
- `;
119
- expect(run(md, { filterTags: ['englishonly', 'future'] }).messages).toHaveLength(0);
120
- });
121
-
122
- test('does NOT warn on cross-tag nesting (future inside englishonly)', () => {
123
- const md = dedent`
124
- # Heading
125
-
126
- Regular content.
127
-
128
- {% englishonly %}
129
- English content.
130
-
131
- {% future %}
132
- Future inside english.
133
- {% endfuture %}
134
-
135
- {% endenglishonly %}
136
-
137
- More content.
138
- `;
139
- expect(run(md, { filterTags: ['englishonly', 'future'] }).messages).toHaveLength(0);
140
- });
141
-
142
- test('warns when entire file is wrapped', () => {
86
+ test('warns when entire file is wrapped even with frontmatter', () => {
143
87
  const md = dedent`
88
+ ---
89
+ title: My Topic
90
+ ---
144
91
  {% englishonly %}
145
92
  # Full Content
146
93
 
@@ -150,7 +97,7 @@ describe('sfdocs-lint:liquid-filtered-blocks', () => {
150
97
  const messages = run(md, { filterTags: ['englishonly'] }).messages;
151
98
  expect(messages).toHaveLength(1);
152
99
  expect(messages[0].message).toBe(
153
- 'All content is inside englishonly block — use `filters: [englishonly]` in TOC instead.',
100
+ 'All content is inside englishonly block — use filters: [englishonly] in TOC instead.',
154
101
  );
155
102
  });
156
103
 
@@ -162,18 +109,18 @@ describe('sfdocs-lint:liquid-filtered-blocks', () => {
162
109
 
163
110
  ## Empty Section
164
111
 
165
- {% future %}
112
+ {% englishonly %}
166
113
  This is the only content under this heading.
167
- {% endfuture %}
114
+ {% endenglishonly %}
168
115
 
169
116
  ## Another Section
170
117
 
171
118
  More regular content.
172
119
  `;
173
- const messages = run(md, { filterTags: ['future'] }).messages;
120
+ const messages = run(md, { filterTags: ['englishonly'] }).messages;
174
121
  expect(messages).toHaveLength(1);
175
122
  expect(messages[0].message).toBe(
176
- 'Heading would have no content after future block is removed.',
123
+ 'Heading will have no content when the englishonly block is filtered out. Remove or adjust the heading.',
177
124
  );
178
125
  });
179
126
 
@@ -192,15 +139,15 @@ describe('sfdocs-lint:liquid-filtered-blocks', () => {
192
139
 
193
140
  test('handles whitespace-control syntax', () => {
194
141
  const md = dedent`
195
- {%- future -%}
196
- {%- future -%}
142
+ {%- englishonly -%}
143
+ {%- englishonly -%}
197
144
  Nested.
198
- {%- endfuture -%}
199
- {%- endfuture -%}
145
+ {%- endenglishonly -%}
146
+ {%- endenglishonly -%}
200
147
  `;
201
- const messages = run(md, { filterTags: ['future'] }).messages;
148
+ const messages = run(md, { filterTags: ['englishonly'] }).messages;
202
149
  expect(messages).toHaveLength(1);
203
- expect(messages[0].message).toContain('Redundant nested future block');
150
+ expect(messages[0].message).toContain('Redundant nested englishonly block');
204
151
  });
205
152
 
206
153
  test('ignores tags inside fenced code blocks', () => {
@@ -228,7 +175,7 @@ describe('sfdocs-lint:liquid-filtered-blocks', () => {
228
175
  Content.
229
176
  {% endenglishonly %}
230
177
  `;
231
- const messages = run(md, { filterTags: ['englishonly', 'future'] }).messages;
178
+ const messages = run(md, { filterTags: ['englishonly'] }).messages;
232
179
  expect(messages).toHaveLength(1);
233
180
  expect(messages[0].message).toContain('englishonly');
234
181
  });
package/src/index.ts CHANGED
@@ -1,56 +1,16 @@
1
1
  import rule from 'unified-lint-rule';
2
+ import visit from 'unist-util-visit';
2
3
  import { VFile } from 'vfile';
3
- import { offsetToPosition, protectCodeBlocks } from '@salesforcedevs/sfdocs-liquid-lint-utils';
4
+ import { offsetToPosition, parseFrontmatter, findBlockRanges, BlockRange } from '@salesforcedevs/sfdocs-liquid-lint-utils';
4
5
 
5
6
  const SOURCE = 'sfdocs-liquid-lint:liquid-filtered-blocks';
6
7
 
7
- interface FilteredBlocksOptions {
8
- filterTags?: string[];
9
- }
10
-
11
- interface BlockRange {
12
- openStart: number;
13
- openEnd: number;
14
- closeStart: number;
15
- closeEnd: number;
16
- }
17
-
18
- function findBlockRanges(content: string, tagName: string): BlockRange[] {
19
- const ranges: BlockRange[] = [];
20
- const tagRe = new RegExp(`{%-?\\s*(${tagName}|end${tagName})\\s*-?%}`, 'g');
21
- const openStack: { index: number; end: number }[] = [];
22
-
23
- let match: RegExpExecArray | null;
24
- while ((match = tagRe.exec(content)) !== null) {
25
- if (match[1] === tagName) {
26
- openStack.push({ index: match.index, end: match.index + match[0].length });
27
- } else if (openStack.length > 0) {
28
- const open = openStack.pop()!;
29
- ranges.push({
30
- openStart: open.index,
31
- openEnd: open.end,
32
- closeStart: match.index,
33
- closeEnd: match.index + match[0].length,
34
- });
35
- }
36
- }
37
- return ranges;
38
- }
39
-
40
8
  function isInsideRange(offset: number, ranges: BlockRange[]): boolean {
41
9
  return ranges.some(r => offset >= r.openStart && offset < r.closeEnd);
42
10
  }
43
11
 
44
- function isNestedBlock(blockIndex: number, ranges: BlockRange[]): boolean {
45
- const block = ranges[blockIndex];
46
- for (let i = 0; i < ranges.length; i++) {
47
- if (i === blockIndex) continue;
48
- const outer = ranges[i];
49
- if (block.openStart > outer.openStart && block.closeEnd < outer.closeEnd) {
50
- return true;
51
- }
52
- }
53
- return false;
12
+ function isNestedBlock(block: BlockRange, ranges: BlockRange[]): boolean {
13
+ return ranges.some(r => r !== block && block.openStart > r.openStart && block.closeEnd < r.closeEnd);
54
14
  }
55
15
 
56
16
  function isEntireFileWrapped(content: string, ranges: BlockRange[]): boolean {
@@ -61,34 +21,27 @@ function isEntireFileWrapped(content: string, ranges: BlockRange[]): boolean {
61
21
  return !/\S/.test(before) && !/\S/.test(after);
62
22
  }
63
23
 
64
- function findEmptySections(content: string, ranges: BlockRange[]): number[] {
65
- const lines = content.split('\n');
66
- const headingPositions: number[] = [];
67
- let offset = 0;
68
-
69
- for (const line of lines) {
70
- if (/^#{1,6}\s+\S/.test(line) && !isInsideRange(offset, ranges)) {
71
- headingPositions.push(offset);
24
+ function findEmptyHeadings(tree: any, content: string, ranges: BlockRange[]): any[] {
25
+ const headings: any[] = [];
26
+ visit(tree, 'heading', (node: any) => {
27
+ const offset = node.position?.start?.offset;
28
+ if (offset !== undefined && !isInsideRange(offset, ranges)) {
29
+ headings.push(node);
72
30
  }
73
- offset += line.length + 1;
74
- }
31
+ });
75
32
 
76
- const emptyHeadingOffsets: number[] = [];
77
- for (let i = 0; i < headingPositions.length; i++) {
78
- const sectionStart = headingPositions[i];
79
- const sectionEnd = i + 1 < headingPositions.length
80
- ? headingPositions[i + 1]
81
- : content.length;
33
+ const emptyHeadings: any[] = [];
34
+ for (let i = 0; i < headings.length; i++) {
35
+ const sectionStart = headings[i].position.end.offset;
36
+ const sectionEnd = headings[i + 1]?.position.start.offset ?? content.length;
37
+ const body = content.slice(sectionStart, sectionEnd);
82
38
 
83
- const headingLine = content.slice(sectionStart, content.indexOf('\n', sectionStart));
84
- const afterHeading = content.slice(sectionStart + headingLine.length, sectionEnd);
85
-
86
- const stripped = stripFilteredBlocks(afterHeading, ranges, sectionStart + headingLine.length);
39
+ const stripped = stripFilteredBlocks(body, ranges, sectionStart);
87
40
  if (!/\S/.test(stripped)) {
88
- emptyHeadingOffsets.push(sectionStart);
41
+ emptyHeadings.push(headings[i]);
89
42
  }
90
43
  }
91
- return emptyHeadingOffsets;
44
+ return emptyHeadings;
92
45
  }
93
46
 
94
47
  function stripFilteredBlocks(section: string, allRanges: BlockRange[], sectionOffset: number): string {
@@ -105,46 +58,53 @@ function stripFilteredBlocks(section: string, allRanges: BlockRange[], sectionOf
105
58
  return result;
106
59
  }
107
60
 
108
- function checkFilteredBlocks(_tree: unknown, file: VFile, option?: FilteredBlocksOptions): void {
109
- if (!file.path) return;
110
- const raw = String(file.contents ?? '');
111
- if (!raw) return;
112
-
113
- const filterTags = option?.filterTags;
114
- if (!filterTags || filterTags.length === 0) return;
115
-
116
- const content = protectCodeBlocks(raw);
61
+ function validateTag(tree: any, file: VFile, raw: string, content: string, tagName: string): void {
62
+ const ranges = findBlockRanges(content, tagName);
63
+ if (ranges.length === 0) return;
117
64
 
118
- for (const tagName of filterTags) {
119
- const ranges = findBlockRanges(content, tagName);
120
- if (ranges.length === 0) continue;
121
-
122
- for (let i = 0; i < ranges.length; i++) {
123
- if (isNestedBlock(i, ranges)) {
124
- const position = offsetToPosition(raw, ranges[i].openStart);
65
+ for (const range of ranges) {
66
+ if (isNestedBlock(range, ranges)) {
67
+ if (tagName === 'englishonly') {
68
+ file.message(
69
+ `Redundant nested englishonly block already inside an englishonly block.`,
70
+ offsetToPosition(raw, range.openStart),
71
+ );
72
+ } else {
125
73
  file.message(
126
74
  `Redundant nested ${tagName} block — already inside a ${tagName} block.`,
127
- position,
75
+ offsetToPosition(raw, range.openStart),
128
76
  );
129
77
  }
130
78
  }
79
+ }
131
80
 
132
- if (isEntireFileWrapped(content, ranges)) {
133
- const position = offsetToPosition(raw, ranges[0].openStart);
134
- file.message(
135
- `All content is inside ${tagName} block — use \`filters: [${tagName}]\` in TOC instead.`,
136
- position,
137
- );
138
- }
81
+ if (isEntireFileWrapped(content, ranges)) {
82
+ file.message(
83
+ `All content is inside ${tagName} block — use filters: [${tagName}] in TOC instead.`,
84
+ offsetToPosition(raw, ranges[0].openStart),
85
+ );
86
+ }
139
87
 
140
- const emptyHeadingOffsets = findEmptySections(content, ranges);
141
- for (const offset of emptyHeadingOffsets) {
142
- const position = offsetToPosition(raw, offset);
143
- file.message(
144
- `Heading would have no content after ${tagName} block is removed.`,
145
- position,
146
- );
147
- }
88
+ for (const heading of findEmptyHeadings(tree, content, ranges)) {
89
+ file.message(
90
+ `Heading will have no content when the ${tagName} block is filtered out. Remove or adjust the heading.`,
91
+ heading,
92
+ );
93
+ }
94
+ }
95
+
96
+ function checkFilteredBlocks(tree: any, file: VFile, option?: { filterTags?: string[] }): void {
97
+ const filterTags = option?.filterTags;
98
+ if (!file.path || !filterTags || filterTags.length === 0) return;
99
+
100
+ const raw = String(file.contents ?? '');
101
+ if (!raw) return;
102
+
103
+ const parsed = parseFrontmatter(raw);
104
+ if (!parsed) return;
105
+
106
+ for (const tagName of filterTags) {
107
+ validateTag(tree, file, raw, parsed.content, tagName);
148
108
  }
149
109
  }
150
110