@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 +35 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -75
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/index.test.ts +26 -79
- package/src/index.ts +58 -98
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.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";
|
|
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(
|
|
33
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
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
|
-
|
|
40
|
+
emptyHeadings.push(headings[i]);
|
|
73
41
|
}
|
|
74
42
|
}
|
|
75
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
97
|
-
if (!
|
|
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
|
-
|
|
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,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;AAAA,0EAAqC;AACrC,wEAAqC;AAErC,uFAA2H;AAE3H,MAAM,MAAM,GAAG,2CAA2C,CAAC;AAE3D,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,KAAiB,EAAE,MAAoB;IAC1D,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,KAAK,IAAI,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;AACzG,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,IAAS,EAAE,OAAe,EAAE,MAAoB;;IACvE,MAAM,QAAQ,GAAU,EAAE,CAAC;IAC3B,IAAA,0BAAK,EAAC,IAAI,EAAE,SAAS,EAAE,CAAC,IAAS,EAAE,EAAE;;QACjC,MAAM,MAAM,GAAG,MAAA,MAAA,IAAI,CAAC,QAAQ,0CAAE,KAAK,0CAAE,MAAM,CAAC;QAC5C,IAAI,MAAM,KAAK,SAAS,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;YACxD,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;SACvB;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,aAAa,GAAU,EAAE,CAAC;IAChC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QACtC,MAAM,YAAY,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC;QACrD,MAAM,UAAU,GAAG,MAAA,MAAA,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,0CAAE,QAAQ,CAAC,KAAK,CAAC,MAAM,mCAAI,OAAO,CAAC,MAAM,CAAC;QAC5E,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;QAErD,MAAM,QAAQ,GAAG,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,CAAC,CAAC;QACjE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE;YACtB,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;SACnC;KACJ;IACD,OAAO,aAAa,CAAC;AACzB,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,WAAW,CAAC,IAAS,EAAE,IAAW,EAAE,GAAW,EAAE,OAAe,EAAE,OAAe;IACtF,MAAM,MAAM,GAAG,IAAA,0CAAe,EAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACjD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAEhC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE;QACxB,IAAI,aAAa,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE;YAC9B,IAAI,OAAO,KAAK,aAAa,EAAE;gBAC3B,IAAI,CAAC,OAAO,CACR,2EAA2E,EAC3E,IAAA,2CAAgB,EAAC,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,CACzC,CAAC;aACL;iBAAM;gBACH,IAAI,CAAC,OAAO,CACR,oBAAoB,OAAO,6BAA6B,OAAO,SAAS,EACxE,IAAA,2CAAgB,EAAC,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,CACzC,CAAC;aACL;SACJ;KACJ;IAED,IAAI,mBAAmB,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE;QACtC,IAAI,CAAC,OAAO,CACR,yBAAyB,OAAO,0BAA0B,OAAO,mBAAmB,EACpF,IAAA,2CAAgB,EAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAC7C,CAAC;KACL;IAED,KAAK,MAAM,OAAO,IAAI,iBAAiB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE;QAC5D,IAAI,CAAC,OAAO,CACR,yCAAyC,OAAO,uDAAuD,EACvG,OAAO,CACV,CAAC;KACL;AACL,CAAC;AAED,SAAS,mBAAmB,CAAC,IAAS,EAAE,IAAW,EAAE,MAAkC;;IACnF,MAAM,UAAU,GAAG,MAAM,aAAN,MAAM,uBAAN,MAAM,CAAE,UAAU,CAAC;IACtC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAEjE,MAAM,GAAG,GAAG,MAAM,CAAC,MAAA,IAAI,CAAC,QAAQ,mCAAI,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,GAAG;QAAE,OAAO;IAEjB,MAAM,MAAM,GAAG,IAAA,2CAAgB,EAAC,GAAG,CAAC,CAAC;IACrC,IAAI,CAAC,MAAM;QAAE,OAAO;IAEpB,KAAK,MAAM,OAAO,IAAI,UAAU,EAAE;QAC9B,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;KACzD;AACL,CAAC;AAED,iBAAS,IAAA,2BAAI,EAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforcedevs/sfdocs-liquid-lint-filtered-blocks",
|
|
3
|
-
"version": "1.0.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
|
|
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
|
|
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
|
|
71
|
+
test('warns when entire file is wrapped', () => {
|
|
85
72
|
const md = dedent`
|
|
86
|
-
{%
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
{% future %}
|
|
90
|
-
Inner.
|
|
91
|
-
{% endfuture %}
|
|
73
|
+
{% englishonly %}
|
|
74
|
+
# Full Content
|
|
92
75
|
|
|
93
|
-
|
|
76
|
+
Everything here.
|
|
77
|
+
{% endenglishonly %}
|
|
94
78
|
`;
|
|
95
|
-
const messages = run(md, { filterTags: ['
|
|
79
|
+
const messages = run(md, { filterTags: ['englishonly'] }).messages;
|
|
96
80
|
expect(messages).toHaveLength(1);
|
|
97
81
|
expect(messages[0].message).toBe(
|
|
98
|
-
'
|
|
82
|
+
'All content is inside englishonly block — use filters: [englishonly] in TOC instead.',
|
|
99
83
|
);
|
|
100
84
|
});
|
|
101
85
|
|
|
102
|
-
test('
|
|
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
|
|
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
|
-
{%
|
|
112
|
+
{% englishonly %}
|
|
166
113
|
This is the only content under this heading.
|
|
167
|
-
{%
|
|
114
|
+
{% endenglishonly %}
|
|
168
115
|
|
|
169
116
|
## Another Section
|
|
170
117
|
|
|
171
118
|
More regular content.
|
|
172
119
|
`;
|
|
173
|
-
const messages = run(md, { filterTags: ['
|
|
120
|
+
const messages = run(md, { filterTags: ['englishonly'] }).messages;
|
|
174
121
|
expect(messages).toHaveLength(1);
|
|
175
122
|
expect(messages[0].message).toBe(
|
|
176
|
-
'Heading
|
|
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
|
-
{%-
|
|
196
|
-
{%-
|
|
142
|
+
{%- englishonly -%}
|
|
143
|
+
{%- englishonly -%}
|
|
197
144
|
Nested.
|
|
198
|
-
{%-
|
|
199
|
-
{%-
|
|
145
|
+
{%- endenglishonly -%}
|
|
146
|
+
{%- endenglishonly -%}
|
|
200
147
|
`;
|
|
201
|
-
const messages = run(md, { filterTags: ['
|
|
148
|
+
const messages = run(md, { filterTags: ['englishonly'] }).messages;
|
|
202
149
|
expect(messages).toHaveLength(1);
|
|
203
|
-
expect(messages[0].message).toContain('Redundant nested
|
|
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'
|
|
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,
|
|
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(
|
|
45
|
-
|
|
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
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
}
|
|
31
|
+
});
|
|
75
32
|
|
|
76
|
-
const
|
|
77
|
-
for (let i = 0; i <
|
|
78
|
-
const sectionStart =
|
|
79
|
-
const sectionEnd = i + 1
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
41
|
+
emptyHeadings.push(headings[i]);
|
|
89
42
|
}
|
|
90
43
|
}
|
|
91
|
-
return
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
75
|
+
offsetToPosition(raw, range.openStart),
|
|
128
76
|
);
|
|
129
77
|
}
|
|
130
78
|
}
|
|
79
|
+
}
|
|
131
80
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|