@platformos/platformos-check-common 0.0.19 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/index.js.map +1 -1
- package/dist/checks/missing-content-for-layout/index.d.ts +2 -0
- package/dist/checks/missing-content-for-layout/index.js +89 -0
- package/dist/checks/missing-content-for-layout/index.js.map +1 -0
- package/dist/checks/reserved-variable-name/index.d.ts +2 -0
- package/dist/checks/reserved-variable-name/index.js +82 -0
- package/dist/checks/reserved-variable-name/index.js.map +1 -0
- package/dist/checks/unused-assign/index.js +4 -0
- package/dist/checks/unused-assign/index.js.map +1 -1
- package/dist/checks/utils.d.ts +7 -0
- package/dist/checks/utils.js +8 -0
- package/dist/checks/utils.js.map +1 -1
- package/dist/find-root.js +15 -2
- package/dist/find-root.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/liquid-doc/utils.d.ts +1 -1
- package/dist/to-source-code.d.ts +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/checks/index.ts +4 -0
- package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +23 -0
- package/src/checks/missing-content-for-layout/index.spec.ts +84 -0
- package/src/checks/missing-content-for-layout/index.ts +94 -0
- package/src/checks/missing-page/index.spec.ts +94 -0
- package/src/checks/reserved-variable-name/index.spec.ts +142 -0
- package/src/checks/reserved-variable-name/index.ts +85 -0
- package/src/checks/unused-assign/index.spec.ts +13 -0
- package/src/checks/unused-assign/index.ts +4 -1
- package/src/checks/utils.ts +11 -0
- package/src/find-root.ts +17 -2
- package/src/index.ts +2 -0
- package/src/types.ts +19 -0
package/dist/types.d.ts
CHANGED
|
@@ -231,10 +231,20 @@ export type Translations = {
|
|
|
231
231
|
* target: 'file:///app/views/partials/child.liquid'
|
|
232
232
|
* }
|
|
233
233
|
*/
|
|
234
|
+
/**
|
|
235
|
+
* The semantic Liquid construct that created a {@link Reference} edge.
|
|
236
|
+
*
|
|
237
|
+
* Optional/additive: older producers may omit it. Lets consumers distinguish a
|
|
238
|
+
* `{% render %}` edge from an `{% include %}` / `{% function %}` / `{% graphql %}`
|
|
239
|
+
* / `{% background %}` / asset / layout-association edge without re-parsing.
|
|
240
|
+
*/
|
|
241
|
+
export type ReferenceKind = 'render' | 'include' | 'function' | 'background' | 'graphql' | 'asset' | 'layout';
|
|
234
242
|
export type Reference = {
|
|
235
243
|
source: Location;
|
|
236
244
|
target: Location;
|
|
237
245
|
type: 'direct' | 'indirect';
|
|
246
|
+
/** Which Liquid construct produced this edge. Optional for backwards compatibility. */
|
|
247
|
+
kind?: ReferenceKind;
|
|
238
248
|
};
|
|
239
249
|
export type Range = [start: number, end: number];
|
|
240
250
|
export type Location = {
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,uEAAkG;AAwEzE,oGAxEa,8BAAmB,OAwEb;AAnD5C,gDAA8B;AAC9B,8DAA4C;AAC5C,iEAA+C;AAExC,MAAM,YAAY,GAAG,CAAC,IAAc,EAAsB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,QAAQ,CAAC;AAA/E,QAAA,YAAY,gBAAmE;AACrF,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,cAAc,GAAG,CAAC,IAAc,EAAwB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,UAAU,CAAC;AAArF,QAAA,cAAc,kBAAuE;AAC3F,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,aAAa,GAAG,CAAC,IAAc,EAAuB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC;AAAlF,QAAA,aAAa,iBAAqE;AAkB/F,IAAY,cAKX;AALD,WAAY,cAAc;IACxB,+BAAa,CAAA;IACb,2CAAyB,CAAA;IACzB,qCAAmB,CAAA;IACnB,+BAAa,CAAA;AACf,CAAC,EALW,cAAc,8BAAd,cAAc,QAKzB;
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,uEAAkG;AAwEzE,oGAxEa,8BAAmB,OAwEb;AAnD5C,gDAA8B;AAC9B,8DAA4C;AAC5C,iEAA+C;AAExC,MAAM,YAAY,GAAG,CAAC,IAAc,EAAsB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,QAAQ,CAAC;AAA/E,QAAA,YAAY,gBAAmE;AACrF,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,cAAc,GAAG,CAAC,IAAc,EAAwB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,UAAU,CAAC;AAArF,QAAA,cAAc,kBAAuE;AAC3F,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,aAAa,GAAG,CAAC,IAAc,EAAuB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC;AAAlF,QAAA,aAAa,iBAAqE;AAkB/F,IAAY,cAKX;AALD,WAAY,cAAc;IACxB,+BAAa,CAAA;IACb,2CAAyB,CAAA;IACzB,qCAAmB,CAAA;IACnB,+BAAa,CAAA;AACf,CAAC,EALW,cAAc,8BAAd,cAAc,QAKzB;AAqgBD,gEAAgE;AAChE,IAAY,QAIX;AAJD,WAAY,QAAQ;IAClB,yCAAS,CAAA;IACT,6CAAW,CAAA;IACX,uCAAQ,CAAA;AACV,CAAC,EAJW,QAAQ,wBAAR,QAAQ,QAInB;AAED,+CAA+C;AAC/C,IAAY,YAGX;AAHD,WAAY,YAAY;IACtB,2BAAW,CAAA;IACX,2CAA2B,CAAA;AAC7B,CAAC,EAHW,YAAY,4BAAZ,YAAY,QAGvB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformos/platformos-check-common",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.20",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"type-check": "tsc --noEmit"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@platformos/liquid-html-parser": "0.0.
|
|
29
|
+
"@platformos/liquid-html-parser": "0.0.18",
|
|
30
30
|
"graphql": "^16.12.0",
|
|
31
31
|
"js-yaml": "^4.1.1",
|
|
32
32
|
"jsonc-parser": "^3.3.1",
|
package/src/checks/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { MissingAsset } from './missing-asset';
|
|
|
17
17
|
import { MissingPartial } from './missing-partial';
|
|
18
18
|
import { OrphanedPartial } from './orphaned-partial';
|
|
19
19
|
import { ParserBlockingScript } from './parser-blocking-script';
|
|
20
|
+
import { ReservedVariableName } from './reserved-variable-name';
|
|
20
21
|
import { TranslationKeyExists } from './translation-key-exists';
|
|
21
22
|
import { UnclosedHTMLElement } from './unclosed-html-element';
|
|
22
23
|
import { UndefinedObject } from './undefined-object';
|
|
@@ -41,6 +42,7 @@ import { NestedGraphQLQuery } from './nested-graphql-query';
|
|
|
41
42
|
import { MissingPage } from './missing-page';
|
|
42
43
|
import { ValidFrontmatter } from './valid-frontmatter';
|
|
43
44
|
import { JsonLiteralQuoteStyle } from './json-literal-quote-style';
|
|
45
|
+
import { MissingContentForLayout } from './missing-content-for-layout';
|
|
44
46
|
|
|
45
47
|
export const allChecks: (
|
|
46
48
|
| LiquidCheckDefinition
|
|
@@ -60,6 +62,7 @@ export const allChecks: (
|
|
|
60
62
|
MissingPartial,
|
|
61
63
|
OrphanedPartial,
|
|
62
64
|
ParserBlockingScript,
|
|
65
|
+
ReservedVariableName,
|
|
63
66
|
TranslationKeyExists,
|
|
64
67
|
UnclosedHTMLElement,
|
|
65
68
|
UndefinedObject,
|
|
@@ -83,6 +86,7 @@ export const allChecks: (
|
|
|
83
86
|
MissingPage,
|
|
84
87
|
ValidFrontmatter,
|
|
85
88
|
JsonLiteralQuoteStyle,
|
|
89
|
+
MissingContentForLayout,
|
|
86
90
|
];
|
|
87
91
|
|
|
88
92
|
/**
|
|
@@ -194,6 +194,29 @@ describe('Module: InvalidTagSyntax', () => {
|
|
|
194
194
|
const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
|
|
195
195
|
expect(syntaxOffenses).toHaveLength(0);
|
|
196
196
|
});
|
|
197
|
+
|
|
198
|
+
it('should not report form without arguments', async () => {
|
|
199
|
+
const sourceCode = `{% form %}content{% endform %}`;
|
|
200
|
+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
|
|
201
|
+
const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
|
|
202
|
+
expect(syntaxOffenses).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should not report form with hyphenated keys and missing commas between attributes', async () => {
|
|
206
|
+
// The runtime scans `[\w-]+: value` pairs out of the markup, so commas
|
|
207
|
+
// between attributes are optional and keys may contain hyphens.
|
|
208
|
+
const sourceCode = `{% form html-class: "foo-form", html-submitonce: "true" html-foo-bar: "true" %}content{% endform %}`;
|
|
209
|
+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
|
|
210
|
+
const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
|
|
211
|
+
expect(syntaxOffenses).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should not report form with a positional model name', async () => {
|
|
215
|
+
const sourceCode = `{% form form, method: 'delete' %}content{% endform %}`;
|
|
216
|
+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
|
|
217
|
+
const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
|
|
218
|
+
expect(syntaxOffenses).toHaveLength(0);
|
|
219
|
+
});
|
|
197
220
|
});
|
|
198
221
|
|
|
199
222
|
describe('inside {% liquid %} blocks', () => {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Severity } from '../../types';
|
|
3
|
+
import { applySuggestions, runLiquidCheck } from '../../test';
|
|
4
|
+
import { MissingContentForLayout } from './index';
|
|
5
|
+
|
|
6
|
+
const LAYOUT = 'app/views/layouts/application.liquid';
|
|
7
|
+
const PAGE = 'app/views/pages/index.liquid';
|
|
8
|
+
const PARTIAL = 'app/views/partials/card.liquid';
|
|
9
|
+
|
|
10
|
+
describe('Module: MissingContentForLayout', () => {
|
|
11
|
+
it('flags a layout that never references content_for_layout', async () => {
|
|
12
|
+
const sourceCode = '<html><body><header>Site</header></body></html>';
|
|
13
|
+
|
|
14
|
+
const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
|
|
15
|
+
|
|
16
|
+
expect(offenses).toHaveLength(1);
|
|
17
|
+
expect(offenses[0].check).toEqual('MissingContentForLayout');
|
|
18
|
+
expect(offenses[0].severity).toEqual(Severity.ERROR);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not flag a layout that outputs {{ content_for_layout }}', async () => {
|
|
22
|
+
const sourceCode = '<html><body>{{ content_for_layout }}</body></html>';
|
|
23
|
+
|
|
24
|
+
const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
|
|
25
|
+
|
|
26
|
+
expect(offenses).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('detects content_for_layout referenced inside a {% liquid %} block via echo', async () => {
|
|
30
|
+
const sourceCode = [
|
|
31
|
+
'<html><body>',
|
|
32
|
+
'{% liquid',
|
|
33
|
+
' echo content_for_layout',
|
|
34
|
+
'%}',
|
|
35
|
+
'</body></html>',
|
|
36
|
+
].join('\n');
|
|
37
|
+
|
|
38
|
+
const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
|
|
39
|
+
|
|
40
|
+
expect(offenses).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not flag non-layout files (pages, partials)', async () => {
|
|
44
|
+
const sourceCode = '<html><body><header>Site</header></body></html>';
|
|
45
|
+
|
|
46
|
+
const pageOffenses = await runLiquidCheck(MissingContentForLayout, sourceCode, PAGE);
|
|
47
|
+
const partialOffenses = await runLiquidCheck(MissingContentForLayout, sourceCode, PARTIAL);
|
|
48
|
+
|
|
49
|
+
expect(pageOffenses).toEqual([]);
|
|
50
|
+
expect(partialOffenses).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('suggests inserting content_for_layout before the closing </body> tag', async () => {
|
|
54
|
+
const sourceCode = '<html><body><header>Site</header></body></html>';
|
|
55
|
+
|
|
56
|
+
const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
|
|
57
|
+
const suggestions = applySuggestions({ [LAYOUT]: sourceCode }, offenses[0]);
|
|
58
|
+
|
|
59
|
+
expect(suggestions).toEqual([
|
|
60
|
+
'<html><body><header>Site</header>{{ content_for_layout }}\n</body></html>',
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('suggests inserting before </body> even when <body> has attributes', async () => {
|
|
65
|
+
const sourceCode =
|
|
66
|
+
'<html><body class="theme" data-env="prod"><header>Site</header></body></html>';
|
|
67
|
+
|
|
68
|
+
const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
|
|
69
|
+
const suggestions = applySuggestions({ [LAYOUT]: sourceCode }, offenses[0]);
|
|
70
|
+
|
|
71
|
+
expect(suggestions).toEqual([
|
|
72
|
+
'<html><body class="theme" data-env="prod"><header>Site</header>{{ content_for_layout }}\n</body></html>',
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('suggests appending content_for_layout when there is no </body> tag', async () => {
|
|
77
|
+
const sourceCode = '<header>Site</header>';
|
|
78
|
+
|
|
79
|
+
const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
|
|
80
|
+
const suggestions = applySuggestions({ [LAYOUT]: sourceCode }, offenses[0]);
|
|
81
|
+
|
|
82
|
+
expect(suggestions).toEqual(['<header>Site</header>\n{{ content_for_layout }}\n']);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NodeTypes } from '@platformos/liquid-html-parser';
|
|
2
|
+
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
3
|
+
import { isLayout } from '../../path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ensures every layout outputs `{{ content_for_layout }}`.
|
|
7
|
+
*
|
|
8
|
+
* `content_for_layout` is where platformOS injects the rendered page body. A
|
|
9
|
+
* layout that never references it renders its own chrome but drops the page
|
|
10
|
+
* entirely — a correctness defect, not a style issue. (Named slots use
|
|
11
|
+
* `{% yield 'name' %}` separately and do not substitute for it.)
|
|
12
|
+
*
|
|
13
|
+
* The check is scoped to layout files via `getFileType` (re-exported as
|
|
14
|
+
* `isLayout`) so it never fires on pages or partials. Both detection and the
|
|
15
|
+
* suggested fix are AST-based: any reference to the `content_for_layout`
|
|
16
|
+
* variable clears the check (whether emitted with `{{ … }}`, `{% echo … %}`,
|
|
17
|
+
* or inside a `{% liquid %}` block), and the fix is inserted before the
|
|
18
|
+
* `<body>` element's closing tag — using the parsed element's position, never
|
|
19
|
+
* a text scan of the raw source.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CONTENT_FOR_LAYOUT = 'content_for_layout';
|
|
23
|
+
|
|
24
|
+
export const MissingContentForLayout: LiquidCheckDefinition = {
|
|
25
|
+
meta: {
|
|
26
|
+
code: 'MissingContentForLayout',
|
|
27
|
+
name: 'Missing Content For Layout',
|
|
28
|
+
docs: {
|
|
29
|
+
description:
|
|
30
|
+
'Ensures every layout references `content_for_layout` — without it the rendered page body is never output.',
|
|
31
|
+
recommended: true,
|
|
32
|
+
url: undefined,
|
|
33
|
+
},
|
|
34
|
+
type: SourceCodeType.LiquidHtml,
|
|
35
|
+
severity: Severity.ERROR,
|
|
36
|
+
schema: {},
|
|
37
|
+
targets: [],
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
create(context) {
|
|
41
|
+
// Scope to layout files only; pages/partials/etc. must not be flagged.
|
|
42
|
+
if (!isLayout(context.file.uri)) return {};
|
|
43
|
+
|
|
44
|
+
let referencesContentForLayout = false;
|
|
45
|
+
// Start index of the `</body>` closing tag, captured from the AST so the
|
|
46
|
+
// fix never has to scan the raw source for it.
|
|
47
|
+
let bodyCloseIndex: number | undefined;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async VariableLookup(node) {
|
|
51
|
+
if (node.name === CONTENT_FOR_LAYOUT) {
|
|
52
|
+
referencesContentForLayout = true;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async HtmlElement(node) {
|
|
57
|
+
if (bodyCloseIndex !== undefined) return; // first <body> wins
|
|
58
|
+
const tagName = node.name[0];
|
|
59
|
+
if (tagName?.type === NodeTypes.TextNode && tagName.value.toLowerCase() === 'body') {
|
|
60
|
+
bodyCloseIndex = node.blockEndPosition.start;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async onCodePathEnd(file) {
|
|
65
|
+
if (referencesContentForLayout) return;
|
|
66
|
+
|
|
67
|
+
// Insert before `</body>` when the layout has one (keeps it inside the
|
|
68
|
+
// body); otherwise append at the end of the document. Both positions
|
|
69
|
+
// come from the AST.
|
|
70
|
+
const hasBody = bodyCloseIndex !== undefined;
|
|
71
|
+
const insertAt = hasBody ? bodyCloseIndex! : file.ast.position.end;
|
|
72
|
+
const insertText = hasBody ? '{{ content_for_layout }}\n' : '\n{{ content_for_layout }}\n';
|
|
73
|
+
const suggestMessage = hasBody
|
|
74
|
+
? 'Insert `{{ content_for_layout }}` before the closing </body> tag'
|
|
75
|
+
: 'Insert `{{ content_for_layout }}` at the end of the layout';
|
|
76
|
+
|
|
77
|
+
context.report({
|
|
78
|
+
message:
|
|
79
|
+
"Layout is missing `{{ content_for_layout }}`. Every layout must output it exactly once — it renders the page body. (Named slots use `{% yield 'name' %}` separately and do not replace it.)",
|
|
80
|
+
startIndex: 0,
|
|
81
|
+
endIndex: 0,
|
|
82
|
+
suggest: [
|
|
83
|
+
{
|
|
84
|
+
message: suggestMessage,
|
|
85
|
+
fix: (corrector) => {
|
|
86
|
+
corrector.insert(insertAt, insertText);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -658,6 +658,100 @@ describe('Module: MissingPage', () => {
|
|
|
658
658
|
});
|
|
659
659
|
});
|
|
660
660
|
|
|
661
|
+
describe('rss format link regression (/blog/rss.rss)', () => {
|
|
662
|
+
it('does not report when the rss format page exists', async () => {
|
|
663
|
+
const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
|
|
664
|
+
const offenses = await runLiquidCheck(
|
|
665
|
+
MissingPage,
|
|
666
|
+
sourceCode,
|
|
667
|
+
'app/views/pages/home.html.liquid',
|
|
668
|
+
{},
|
|
669
|
+
{
|
|
670
|
+
'app/views/pages/blog/rss.rss.liquid': '<rss></rss>',
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
expect(offenses.map((o) => o.message)).toEqual([]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('does not report when the page declares format rss in frontmatter', async () => {
|
|
677
|
+
const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
|
|
678
|
+
const offenses = await runLiquidCheck(
|
|
679
|
+
MissingPage,
|
|
680
|
+
sourceCode,
|
|
681
|
+
'app/views/pages/home.html.liquid',
|
|
682
|
+
{},
|
|
683
|
+
{
|
|
684
|
+
'app/views/pages/blog/rss.liquid': '---\nformat: rss\n---\n<rss></rss>',
|
|
685
|
+
},
|
|
686
|
+
);
|
|
687
|
+
expect(offenses.map((o) => o.message)).toEqual([]);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('reports when no page matches the rss URL', async () => {
|
|
691
|
+
const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
|
|
692
|
+
const offenses = await runLiquidCheck(
|
|
693
|
+
MissingPage,
|
|
694
|
+
sourceCode,
|
|
695
|
+
'app/views/pages/home.html.liquid',
|
|
696
|
+
{},
|
|
697
|
+
{
|
|
698
|
+
'app/views/pages/blog/index.html.liquid': '<h1>Blog</h1>',
|
|
699
|
+
},
|
|
700
|
+
);
|
|
701
|
+
expect(offenses.map((o) => o.message)).toEqual([
|
|
702
|
+
"No page found for route '/blog/rss.rss' (GET)",
|
|
703
|
+
]);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('reports when only an html page exists at the rss URL path', async () => {
|
|
707
|
+
const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
|
|
708
|
+
const offenses = await runLiquidCheck(
|
|
709
|
+
MissingPage,
|
|
710
|
+
sourceCode,
|
|
711
|
+
'app/views/pages/home.html.liquid',
|
|
712
|
+
{},
|
|
713
|
+
{
|
|
714
|
+
'app/views/pages/blog/rss.html.liquid': '<h1>Not a feed</h1>',
|
|
715
|
+
},
|
|
716
|
+
);
|
|
717
|
+
expect(offenses.map((o) => o.message)).toEqual([
|
|
718
|
+
"No page found for route '/blog/rss.rss' (GET)",
|
|
719
|
+
]);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('reports when the module page serves the feed as xml (the platformos-blog scenario)', async () => {
|
|
723
|
+
const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
|
|
724
|
+
const offenses = await runLiquidCheck(
|
|
725
|
+
MissingPage,
|
|
726
|
+
sourceCode,
|
|
727
|
+
'modules/blog/public/views/partials/layout/footer.liquid',
|
|
728
|
+
{},
|
|
729
|
+
{
|
|
730
|
+
'modules/blog/public/views/pages/blog/rss.liquid':
|
|
731
|
+
"---\nslug: 'blog/rss'\nformat: xml\n---\n<rss></rss>",
|
|
732
|
+
},
|
|
733
|
+
);
|
|
734
|
+
expect(offenses.map((o) => o.message)).toEqual([
|
|
735
|
+
"No page found for route '/blog/rss.rss' (GET)",
|
|
736
|
+
]);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('does not report the xml link when the module page serves the feed as xml', async () => {
|
|
740
|
+
const sourceCode = '<a href="/blog/rss.xml">RSS</a>';
|
|
741
|
+
const offenses = await runLiquidCheck(
|
|
742
|
+
MissingPage,
|
|
743
|
+
sourceCode,
|
|
744
|
+
'modules/blog/public/views/partials/layout/footer.liquid',
|
|
745
|
+
{},
|
|
746
|
+
{
|
|
747
|
+
'modules/blog/public/views/pages/blog/rss.liquid':
|
|
748
|
+
"---\nslug: 'blog/rss'\nformat: xml\n---\n<rss></rss>",
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
expect(offenses.map((o) => o.message)).toEqual([]);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
661
755
|
describe('assign variable scoping per position', () => {
|
|
662
756
|
it('resolves each link against the variable value at that point in the document', async () => {
|
|
663
757
|
const sourceCode =
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { highlightedOffenses, runLiquidCheck } from '../../test';
|
|
3
|
+
import { ReservedVariableName } from './index';
|
|
4
|
+
|
|
5
|
+
const message = (name: string) =>
|
|
6
|
+
`'${name}' is a reserved Liquid literal and cannot be used as a variable name — reading '${name}' always returns the literal, never the assigned value`;
|
|
7
|
+
|
|
8
|
+
async function expectReservedOffense(sourceCode: string, name: string, highlight = name) {
|
|
9
|
+
const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
|
|
10
|
+
|
|
11
|
+
expect(offenses.map((offense) => offense.message)).toEqual([message(name)]);
|
|
12
|
+
expect(highlightedOffenses(sourceCode, offenses)).toEqual([highlight]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Module: ReservedVariableName', () => {
|
|
16
|
+
it('reports assigning to empty even when the name is referenced later', async () => {
|
|
17
|
+
const sourceCode = [
|
|
18
|
+
'{% liquid',
|
|
19
|
+
" assign empty = '{}' | parse_json",
|
|
20
|
+
" function invalid = 'modules/blog/commands/blog_instances/build', object: empty",
|
|
21
|
+
'%}',
|
|
22
|
+
].join('\n');
|
|
23
|
+
|
|
24
|
+
await expectReservedOffense(sourceCode, 'empty');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reports every reserved literal as an assign target', async () => {
|
|
28
|
+
const sourceCode = [
|
|
29
|
+
'{% liquid',
|
|
30
|
+
" assign true = 'a'",
|
|
31
|
+
" assign false = 'a'",
|
|
32
|
+
" assign nil = 'a'",
|
|
33
|
+
" assign null = 'a'",
|
|
34
|
+
" assign empty = 'a'",
|
|
35
|
+
" assign blank = 'a'",
|
|
36
|
+
'%}',
|
|
37
|
+
].join('\n');
|
|
38
|
+
|
|
39
|
+
const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
|
|
40
|
+
|
|
41
|
+
expect(offenses.map((offense) => offense.message)).toEqual([
|
|
42
|
+
message('true'),
|
|
43
|
+
message('false'),
|
|
44
|
+
message('nil'),
|
|
45
|
+
message('null'),
|
|
46
|
+
message('empty'),
|
|
47
|
+
message('blank'),
|
|
48
|
+
]);
|
|
49
|
+
expect(highlightedOffenses(sourceCode, offenses)).toEqual([
|
|
50
|
+
'true',
|
|
51
|
+
'false',
|
|
52
|
+
'nil',
|
|
53
|
+
'null',
|
|
54
|
+
'empty',
|
|
55
|
+
'blank',
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reports a reserved name as a capture target', async () => {
|
|
60
|
+
await expectReservedOffense('{% capture blank %}hello{% endcapture %}', 'blank');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reports a reserved name as a function result target', async () => {
|
|
64
|
+
await expectReservedOffense("{% function null = 'lib/queries/find', id: 1 %}", 'null');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reports a reserved name as a graphql result target', async () => {
|
|
68
|
+
await expectReservedOffense("{% graphql empty = 'my/query' %}", 'empty');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports a reserved name as an inline graphql result target', async () => {
|
|
72
|
+
await expectReservedOffense(
|
|
73
|
+
'{% graphql blank %}query { records { id } }{% endgraphql %}',
|
|
74
|
+
'blank',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('reports a reserved name as a parse_json target', async () => {
|
|
79
|
+
await expectReservedOffense('{% parse_json empty %}{ "a": 1 }{% endparse_json %}', 'empty');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reports a reserved name as a hash_assign target', async () => {
|
|
83
|
+
await expectReservedOffense(
|
|
84
|
+
"{% hash_assign empty['key'] = 'value' %}",
|
|
85
|
+
'empty',
|
|
86
|
+
"empty['key']",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reports a reserved name as a for loop variable', async () => {
|
|
91
|
+
await expectReservedOffense('{% for empty in items %}{{ empty }}{% endfor %}', 'empty');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('reports a reserved name as a background job id', async () => {
|
|
95
|
+
await expectReservedOffense("{% background nil = 'lib/jobs/cleanup' %}", 'nil');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('reports a reserved name as a catch error variable', async () => {
|
|
99
|
+
await expectReservedOffense('{% try %}{% catch empty %}{{ empty }}{% endtry %}', 'empty');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('reports a reserved name as an increment target', async () => {
|
|
103
|
+
await expectReservedOffense('{% increment empty %}', 'empty');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does not report non-reserved variable names', async () => {
|
|
107
|
+
const sourceCode = [
|
|
108
|
+
'{% liquid',
|
|
109
|
+
" assign my_empty = '{}' | parse_json",
|
|
110
|
+
" function result = 'lib/queries/find', object: my_empty",
|
|
111
|
+
' echo result',
|
|
112
|
+
'%}',
|
|
113
|
+
].join('\n');
|
|
114
|
+
|
|
115
|
+
const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
|
|
116
|
+
|
|
117
|
+
expect(offenses).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not report reading reserved literals', async () => {
|
|
121
|
+
const sourceCode = [
|
|
122
|
+
'{% liquid',
|
|
123
|
+
' if items == empty or name == blank',
|
|
124
|
+
' echo "nothing"',
|
|
125
|
+
' endif',
|
|
126
|
+
' assign flag = true',
|
|
127
|
+
'%}',
|
|
128
|
+
].join('\n');
|
|
129
|
+
|
|
130
|
+
const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
|
|
131
|
+
|
|
132
|
+
expect(offenses).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not report names that merely contain a reserved word', async () => {
|
|
136
|
+
const sourceCode = "{% assign empty_result = '{}' | parse_json %}{{ empty_result }}";
|
|
137
|
+
|
|
138
|
+
const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
|
|
139
|
+
|
|
140
|
+
expect(offenses).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { LiquidVariableLookup, NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
|
|
2
|
+
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
3
|
+
import { RESERVED_VARIABLE_NAMES } from '../utils';
|
|
4
|
+
|
|
5
|
+
export const ReservedVariableName: LiquidCheckDefinition = {
|
|
6
|
+
meta: {
|
|
7
|
+
code: 'ReservedVariableName',
|
|
8
|
+
name: 'Reserved variable name',
|
|
9
|
+
docs: {
|
|
10
|
+
description:
|
|
11
|
+
"Disallows using reserved Liquid literals ('true', 'false', 'nil', 'null', 'empty', 'blank') as variable names. Reading such a name always returns the built-in literal, never the assigned value.",
|
|
12
|
+
recommended: true,
|
|
13
|
+
url: undefined,
|
|
14
|
+
},
|
|
15
|
+
type: SourceCodeType.LiquidHtml,
|
|
16
|
+
severity: Severity.ERROR,
|
|
17
|
+
schema: {},
|
|
18
|
+
targets: [],
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
create(context) {
|
|
22
|
+
const report = (name: string, startIndex: number, endIndex: number) => {
|
|
23
|
+
context.report({
|
|
24
|
+
message: `'${name}' is a reserved Liquid literal and cannot be used as a variable name — reading '${name}' always returns the literal, never the assigned value`,
|
|
25
|
+
startIndex,
|
|
26
|
+
endIndex,
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Targets whose name is a plain string always sit at the start of their markup node
|
|
31
|
+
const checkStringTarget = (name: string, markup: { position: { start: number } }) => {
|
|
32
|
+
if (RESERVED_VARIABLE_NAMES.has(name)) {
|
|
33
|
+
report(name, markup.position.start, markup.position.start + name.length);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const checkLookupTarget = (lookup: LiquidVariableLookup | null) => {
|
|
38
|
+
if (lookup?.name && RESERVED_VARIABLE_NAMES.has(lookup.name)) {
|
|
39
|
+
report(lookup.name, lookup.position.start, lookup.position.end);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
async LiquidTag(node) {
|
|
45
|
+
if (typeof node.markup === 'string') return;
|
|
46
|
+
|
|
47
|
+
switch (node.name) {
|
|
48
|
+
case NamedTags.assign:
|
|
49
|
+
checkStringTarget(node.markup.name, node.markup);
|
|
50
|
+
break;
|
|
51
|
+
case NamedTags.capture:
|
|
52
|
+
case NamedTags.parse_json:
|
|
53
|
+
case NamedTags.increment:
|
|
54
|
+
case NamedTags.decrement:
|
|
55
|
+
checkLookupTarget(node.markup);
|
|
56
|
+
break;
|
|
57
|
+
case NamedTags.function:
|
|
58
|
+
checkLookupTarget(node.markup.name);
|
|
59
|
+
break;
|
|
60
|
+
case NamedTags.graphql:
|
|
61
|
+
checkStringTarget(node.markup.name, node.markup);
|
|
62
|
+
break;
|
|
63
|
+
case NamedTags.hash_assign:
|
|
64
|
+
checkLookupTarget(node.markup.target);
|
|
65
|
+
break;
|
|
66
|
+
case NamedTags.for:
|
|
67
|
+
case NamedTags.tablerow:
|
|
68
|
+
checkStringTarget(node.markup.variableName, node.markup);
|
|
69
|
+
break;
|
|
70
|
+
case NamedTags.background:
|
|
71
|
+
if (node.markup.type === NodeTypes.BackgroundMarkup) {
|
|
72
|
+
checkStringTarget(node.markup.jobId, node.markup);
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async LiquidBranch(node) {
|
|
79
|
+
if (node.name === NamedTags.catch && typeof node.markup !== 'string') {
|
|
80
|
+
checkLookupTarget(node.markup);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -236,6 +236,19 @@ describe('Module: UnusedAssign', () => {
|
|
|
236
236
|
expect(offenses).to.have.lengthOf(0);
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
it('should not report assignments to reserved literal names (handled by ReservedVariableName)', async () => {
|
|
240
|
+
const sourceCode = `
|
|
241
|
+
{% liquid
|
|
242
|
+
assign empty = '{}' | parse_json
|
|
243
|
+
function invalid = 'modules/blog/commands/blog_instances/build', object: empty
|
|
244
|
+
echo invalid
|
|
245
|
+
%}
|
|
246
|
+
`;
|
|
247
|
+
|
|
248
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
249
|
+
expect(offenses).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
|
|
239
252
|
it('should not report variables referenced in a string-markup fallback assign (parse failure)', async () => {
|
|
240
253
|
// The stray `}` before `%}` causes the tolerant parser to fall back to
|
|
241
254
|
// string markup. The AST contains no VariableLookup nodes for k/v, so
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
NodeTypes,
|
|
7
7
|
} from '@platformos/liquid-html-parser';
|
|
8
8
|
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
9
|
-
import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
|
|
9
|
+
import { isWithinRawTagThatDoesNotParseItsContents, RESERVED_VARIABLE_NAMES } from '../utils';
|
|
10
10
|
|
|
11
11
|
export const UnusedAssign: LiquidCheckDefinition = {
|
|
12
12
|
meta: {
|
|
@@ -95,6 +95,9 @@ export const UnusedAssign: LiquidCheckDefinition = {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
for (const [variable, node] of assignedVariables.entries()) {
|
|
98
|
+
// Reserved literal names are reported by ReservedVariableName instead —
|
|
99
|
+
// "unused" would be misleading when the source visibly references the name.
|
|
100
|
+
if (RESERVED_VARIABLE_NAMES.has(variable)) continue;
|
|
98
101
|
if (!usedVariables.has(variable) && !variable.startsWith('_')) {
|
|
99
102
|
context.report({
|
|
100
103
|
message: `The variable '${variable}' is assigned but not used`,
|