@platformos/platformos-check-common 0.0.18 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/checks/index.js +4 -0
  3. package/dist/checks/index.js.map +1 -1
  4. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.d.ts +11 -0
  5. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js +34 -1
  6. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js.map +1 -1
  7. package/dist/checks/liquid-html-syntax-error/index.js +8 -0
  8. package/dist/checks/liquid-html-syntax-error/index.js.map +1 -1
  9. package/dist/checks/missing-content-for-layout/index.d.ts +2 -0
  10. package/dist/checks/missing-content-for-layout/index.js +89 -0
  11. package/dist/checks/missing-content-for-layout/index.js.map +1 -0
  12. package/dist/checks/reserved-variable-name/index.d.ts +2 -0
  13. package/dist/checks/reserved-variable-name/index.js +82 -0
  14. package/dist/checks/reserved-variable-name/index.js.map +1 -0
  15. package/dist/checks/unused-assign/index.js +21 -0
  16. package/dist/checks/unused-assign/index.js.map +1 -1
  17. package/dist/checks/utils.d.ts +7 -0
  18. package/dist/checks/utils.js +8 -0
  19. package/dist/checks/utils.js.map +1 -1
  20. package/dist/find-root.js +15 -2
  21. package/dist/find-root.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +3 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/liquid-doc/utils.d.ts +1 -1
  26. package/dist/to-source-code.d.ts +1 -1
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/dist/types.d.ts +10 -0
  29. package/dist/types.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/checks/index.ts +4 -0
  32. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.spec.ts +49 -0
  33. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.ts +36 -2
  34. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +23 -0
  35. package/src/checks/liquid-html-syntax-error/index.ts +12 -1
  36. package/src/checks/missing-content-for-layout/index.spec.ts +84 -0
  37. package/src/checks/missing-content-for-layout/index.ts +94 -0
  38. package/src/checks/missing-page/index.spec.ts +94 -0
  39. package/src/checks/reserved-variable-name/index.spec.ts +142 -0
  40. package/src/checks/reserved-variable-name/index.ts +85 -0
  41. package/src/checks/unused-assign/index.spec.ts +61 -0
  42. package/src/checks/unused-assign/index.ts +19 -1
  43. package/src/checks/utils.ts +11 -0
  44. package/src/find-root.ts +17 -2
  45. package/src/index.ts +2 -0
  46. 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;AAkfD,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"}
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.18",
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.16",
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",
@@ -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
  /**
@@ -92,6 +92,55 @@ describe('detectInvalidAssignSyntax', () => {
92
92
  });
93
93
  });
94
94
 
95
+ describe('trailing garbage after RHS / filters (fallback)', () => {
96
+ // These cases have a valid `target = value` skeleton and a valid filter chain,
97
+ // but extra non-parseable characters trail the filters. The tolerant parser
98
+ // swallows the entire body as string markup, and none of the other sub-checks
99
+ // (MultipleAssignValues, InvalidFilterName, InvalidPipeSyntax) detects the
100
+ // problem — so the fallback re-parses in strict mode and surfaces it.
101
+ const fallbackCases: Array<[string, string]> = [
102
+ [
103
+ 'stray `}` after filter array argument',
104
+ `{% assign name = arr | default: [ "hi", k, v] } %}`,
105
+ ],
106
+ ['trailing bare word after filter arg', `{% assign x = y | default: "z" trailing %}`],
107
+ ];
108
+
109
+ for (const [label, sourceCode] of fallbackCases) {
110
+ it(`should report: ${label} — ${sourceCode}`, async () => {
111
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
112
+ const syntaxOffenses = offenses.filter((o) =>
113
+ o.message.includes("Invalid syntax for tag 'assign'"),
114
+ );
115
+ expect(syntaxOffenses).toHaveLength(1);
116
+ });
117
+ }
118
+
119
+ // These cases are already flagged by other sub-checks with different messages;
120
+ // the fallback must NOT also fire on them (no double-reporting).
121
+ const alreadyCoveredCases: Array<[string, string]> = [
122
+ [
123
+ 'stray `}` after unary filter (InvalidFilterName catches it)',
124
+ `{% assign x = y | upcase } %}`,
125
+ ],
126
+ [
127
+ 'stray `}` after RHS with no filter (MultipleAssignValues catches it)',
128
+ `{% assign x = "v" } %}`,
129
+ ],
130
+ ];
131
+
132
+ for (const [label, sourceCode] of alreadyCoveredCases) {
133
+ it(`should NOT double-report: ${label} — ${sourceCode}`, async () => {
134
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
135
+ expect(offenses.length).toBeGreaterThan(0);
136
+ const fallbackOffenses = offenses.filter((o) =>
137
+ o.message.includes("Invalid syntax for tag 'assign'"),
138
+ );
139
+ expect(fallbackOffenses).toHaveLength(0);
140
+ });
141
+ }
142
+ });
143
+
95
144
  describe('valid syntax — should NOT report', () => {
96
145
  const validCases: Array<[string, string]> = [
97
146
  // primitives
@@ -1,6 +1,8 @@
1
- import { LiquidTag } from '@platformos/liquid-html-parser';
1
+ import { LiquidTag, toLiquidAST } from '@platformos/liquid-html-parser';
2
2
  import { Problem, SourceCodeType } from '../../..';
3
3
 
4
+ const INVALID_ASSIGN_MESSAGE = `Invalid syntax for tag 'assign'. Expected syntax: {% assign <var> = <value> %}`;
5
+
4
6
  /**
5
7
  * Detects structurally-invalid `assign` tags that neither `MultipleAssignValues` nor
6
8
  * `InvalidPipeSyntax`/`InvalidFilterName` catch:
@@ -38,12 +40,44 @@ export function detectInvalidAssignSyntax(
38
40
  if (!isStructurallyBroken) return;
39
41
 
40
42
  return {
41
- message: `Invalid syntax for tag 'assign'. Expected syntax: {% assign <var> = <value> %}`,
43
+ message: INVALID_ASSIGN_MESSAGE,
42
44
  startIndex: node.position.start,
43
45
  endIndex: node.position.end,
44
46
  };
45
47
  }
46
48
 
49
+ /**
50
+ * Fallback for assign tags where the tolerant parser landed in string markup even
51
+ * though the `target = value` skeleton looks fine — meaning the value or filter
52
+ * chain has parse-breaking characters (e.g. a stray `}` before `%}`) that no other
53
+ * dedicated sub-check (MultipleAssignValues, InvalidFilterName, InvalidPipeSyntax)
54
+ * surfaced. Re-parses the tag source in strict mode and reports on failure.
55
+ *
56
+ * Must run ONLY when no other sub-check already reported on this tag, otherwise
57
+ * it double-flags the same problem. The orchestrator enforces that gate.
58
+ */
59
+ export function detectInvalidAssignFallback(
60
+ node: LiquidTag,
61
+ ): Problem<SourceCodeType.LiquidHtml> | undefined {
62
+ if (node.name !== 'assign' || typeof node.markup !== 'string') return;
63
+
64
+ // Digit-starting targets (e.g. `23_hours_ago`) are accepted by the platformOS
65
+ // runtime but rejected by the Ohm grammar's `variableSegment` rule. Skipping
66
+ // them here mirrors the intentional tolerance in isValidAssignTarget above.
67
+ if (/^\s*\d/.test(node.markup)) return;
68
+
69
+ const tagSource = node.source.slice(node.position.start, node.position.end);
70
+ try {
71
+ toLiquidAST(tagSource, { mode: 'strict', allowUnclosedDocumentNode: true });
72
+ } catch {
73
+ return {
74
+ message: INVALID_ASSIGN_MESSAGE,
75
+ startIndex: node.position.start,
76
+ endIndex: node.position.end,
77
+ };
78
+ }
79
+ }
80
+
47
81
  /**
48
82
  * Rejects an LHS that is obviously not an assign target.
49
83
  *
@@ -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', () => {
@@ -1,7 +1,10 @@
1
1
  import { Severity, SourceCodeType, LiquidCheckDefinition, Problem } from '../../types';
2
2
  import { getOffset, isError } from '../../utils';
3
3
  import { detectMultipleAssignValues } from './checks/MultipleAssignValues';
4
- import { detectInvalidAssignSyntax } from './checks/InvalidAssignSyntax';
4
+ import {
5
+ detectInvalidAssignSyntax,
6
+ detectInvalidAssignFallback,
7
+ } from './checks/InvalidAssignSyntax';
5
8
  import { detectInvalidBooleanExpressions } from './checks/InvalidBooleanExpressions';
6
9
  import { detectInvalidEchoValue } from './checks/InvalidEchoValue';
7
10
  import { detectInvalidConditionalNode } from './checks/InvalidConditionalNode';
@@ -123,6 +126,14 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
123
126
  if (pipeProblems.length > 0) {
124
127
  pipeProblems.forEach((pipeProblem) => context.report(pipeProblem));
125
128
  }
129
+
130
+ // Last-chance check for assign tags whose tolerant parse fell back to
131
+ // string markup (e.g. stray `}` before `%}`). Gated on "nothing else
132
+ // reported on this tag" to avoid double-flagging.
133
+ if (problems.length + filterProblems.length + pipeProblems.length === 0) {
134
+ const fallback = detectInvalidAssignFallback(node);
135
+ if (fallback) context.report(fallback);
136
+ }
126
137
  },
127
138
 
128
139
  async LiquidBranch(node, ancestors) {
@@ -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
+ });