@platformos/platformos-check-common 0.0.12 → 0.0.13
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 +31 -0
- package/dist/checks/circular-render/index.d.ts +2 -0
- package/dist/checks/circular-render/index.js +164 -0
- package/dist/checks/circular-render/index.js.map +1 -0
- package/dist/checks/index.d.ts +1 -1
- package/dist/checks/index.js +6 -0
- package/dist/checks/index.js.map +1 -1
- package/dist/checks/missing-page/index.d.ts +2 -0
- package/dist/checks/missing-page/index.js +73 -0
- package/dist/checks/missing-page/index.js.map +1 -0
- package/dist/checks/missing-partial/index.js +31 -31
- package/dist/checks/missing-partial/index.js.map +1 -1
- package/dist/checks/missing-render-partial-arguments/index.d.ts +2 -0
- package/dist/checks/missing-render-partial-arguments/index.js +37 -0
- package/dist/checks/missing-render-partial-arguments/index.js.map +1 -0
- package/dist/checks/nested-graphql-query/index.d.ts +2 -0
- package/dist/checks/nested-graphql-query/index.js +146 -0
- package/dist/checks/nested-graphql-query/index.js.map +1 -0
- package/dist/checks/translation-key-exists/index.js +16 -19
- package/dist/checks/translation-key-exists/index.js.map +1 -1
- package/dist/checks/translation-utils.d.ts +20 -0
- package/dist/checks/translation-utils.js +51 -0
- package/dist/checks/translation-utils.js.map +1 -0
- package/dist/checks/undefined-object/index.js +21 -0
- package/dist/checks/undefined-object/index.js.map +1 -1
- package/dist/checks/unused-translation-key/index.d.ts +4 -0
- package/dist/checks/unused-translation-key/index.js +85 -0
- package/dist/checks/unused-translation-key/index.js.map +1 -0
- package/dist/checks/valid-render-partial-argument-types/index.js +2 -1
- package/dist/checks/valid-render-partial-argument-types/index.js.map +1 -1
- package/dist/context-utils.d.ts +2 -1
- package/dist/context-utils.js +31 -1
- package/dist/context-utils.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/liquid-doc/arguments.js +4 -0
- package/dist/liquid-doc/arguments.js.map +1 -1
- package/dist/liquid-doc/utils.d.ts +10 -2
- package/dist/liquid-doc/utils.js +26 -1
- package/dist/liquid-doc/utils.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.js.map +1 -1
- package/dist/url-helpers.d.ts +55 -0
- package/dist/url-helpers.js +334 -0
- package/dist/url-helpers.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/levenshtein.d.ts +3 -0
- package/dist/utils/levenshtein.js +39 -0
- package/dist/utils/levenshtein.js.map +1 -0
- package/package.json +2 -2
- package/src/checks/graphql/index.spec.ts +2 -2
- package/src/checks/index.ts +6 -0
- package/src/checks/missing-page/index.spec.ts +755 -0
- package/src/checks/missing-page/index.ts +89 -0
- package/src/checks/missing-partial/index.spec.ts +361 -0
- package/src/checks/missing-partial/index.ts +39 -47
- package/src/checks/missing-render-partial-arguments/index.spec.ts +74 -0
- package/src/checks/missing-render-partial-arguments/index.ts +44 -0
- package/src/checks/nested-graphql-query/index.spec.ts +175 -0
- package/src/checks/nested-graphql-query/index.ts +203 -0
- package/src/checks/parser-blocking-script/index.spec.ts +7 -3
- package/src/checks/translation-key-exists/index.spec.ts +79 -2
- package/src/checks/translation-key-exists/index.ts +18 -27
- package/src/checks/translation-utils.ts +63 -0
- package/src/checks/undefined-object/index.spec.ts +30 -0
- package/src/checks/undefined-object/index.ts +27 -1
- package/src/checks/unused-assign/index.spec.ts +1 -1
- package/src/checks/unused-doc-param/index.spec.ts +4 -2
- package/src/checks/valid-doc-param-types/index.spec.ts +1 -1
- package/src/checks/valid-render-partial-argument-types/index.spec.ts +24 -1
- package/src/checks/valid-render-partial-argument-types/index.ts +3 -2
- package/src/checks/variable-name/index.spec.ts +1 -1
- package/src/context-utils.ts +33 -1
- package/src/index.ts +3 -0
- package/src/liquid-doc/arguments.ts +6 -0
- package/src/liquid-doc/utils.ts +26 -2
- package/src/types.ts +9 -1
- package/src/url-helpers.spec.ts +241 -0
- package/src/url-helpers.ts +363 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/levenshtein.ts +41 -0
|
@@ -541,4 +541,34 @@ describe('Module: UndefinedObject', () => {
|
|
|
541
541
|
expect(offenses).toHaveLength(1);
|
|
542
542
|
expect(offenses[0].message).toBe("Unknown object 'groups_data' used.");
|
|
543
543
|
});
|
|
544
|
+
|
|
545
|
+
it('should not report an offense for catch variable inside catch block', async () => {
|
|
546
|
+
const sourceCode = `
|
|
547
|
+
{% try %}
|
|
548
|
+
{{ "something" }}
|
|
549
|
+
{% catch error %}
|
|
550
|
+
{{ error }}
|
|
551
|
+
{% endtry %}
|
|
552
|
+
`;
|
|
553
|
+
|
|
554
|
+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
|
|
555
|
+
|
|
556
|
+
expect(offenses).toHaveLength(0);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should report an offense for catch variable used outside catch block', async () => {
|
|
560
|
+
const sourceCode = `
|
|
561
|
+
{% try %}
|
|
562
|
+
{{ "something" }}
|
|
563
|
+
{% catch error %}
|
|
564
|
+
{{ error }}
|
|
565
|
+
{% endtry %}
|
|
566
|
+
{{ error }}
|
|
567
|
+
`;
|
|
568
|
+
|
|
569
|
+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
|
|
570
|
+
|
|
571
|
+
expect(offenses).toHaveLength(1);
|
|
572
|
+
expect(offenses[0].message).toBe("Unknown object 'error' used.");
|
|
573
|
+
});
|
|
544
574
|
});
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
LiquidTagIncrement,
|
|
10
10
|
LiquidTagTablerow,
|
|
11
11
|
LiquidVariableLookup,
|
|
12
|
-
LiquidTagFunction,
|
|
13
12
|
NamedTags,
|
|
14
13
|
NodeTypes,
|
|
15
14
|
Position,
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
import { LiquidCheckDefinition, Severity, SourceCodeType, PlatformOSDocset } from '../../types';
|
|
24
23
|
import { isError, last } from '../../utils';
|
|
25
24
|
import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
|
|
25
|
+
import yaml from 'js-yaml';
|
|
26
26
|
|
|
27
27
|
type Scope = { start?: number; end?: number };
|
|
28
28
|
|
|
@@ -158,6 +158,24 @@ export const UndefinedObject: LiquidCheckDefinition = {
|
|
|
158
158
|
}
|
|
159
159
|
},
|
|
160
160
|
|
|
161
|
+
async LiquidBranch(node, ancestors) {
|
|
162
|
+
if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
|
|
163
|
+
|
|
164
|
+
// {% try %} ... {% catch error %} registers the error variable
|
|
165
|
+
if (
|
|
166
|
+
node.name === NamedTags.catch &&
|
|
167
|
+
node.markup &&
|
|
168
|
+
typeof node.markup !== 'string' &&
|
|
169
|
+
'name' in node.markup &&
|
|
170
|
+
node.markup.name
|
|
171
|
+
) {
|
|
172
|
+
indexVariableScope(node.markup.name, {
|
|
173
|
+
start: node.blockStartPosition.end,
|
|
174
|
+
end: node.blockEndPosition?.start,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
161
179
|
async VariableLookup(node, ancestors) {
|
|
162
180
|
if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
|
|
163
181
|
|
|
@@ -166,6 +184,8 @@ export const UndefinedObject: LiquidCheckDefinition = {
|
|
|
166
184
|
if (isLiquidTag(parent) && isLiquidTagParseJson(parent)) return;
|
|
167
185
|
// Skip the result variable of function tags (it's a definition, not a usage)
|
|
168
186
|
if (isFunctionMarkup(parent) && parent.name === node) return;
|
|
187
|
+
// Skip the error variable definition in catch branches
|
|
188
|
+
if (isLiquidBranchCatch(parent) && parent.markup === node) return;
|
|
169
189
|
|
|
170
190
|
variables.push(node);
|
|
171
191
|
},
|
|
@@ -313,3 +333,9 @@ function isLiquidTagBackground(
|
|
|
313
333
|
function isFunctionMarkup(node?: LiquidHtmlNode): node is FunctionMarkup {
|
|
314
334
|
return node?.type === NodeTypes.FunctionMarkup;
|
|
315
335
|
}
|
|
336
|
+
|
|
337
|
+
function isLiquidBranchCatch(
|
|
338
|
+
node?: LiquidHtmlNode,
|
|
339
|
+
): node is LiquidHtmlNode & { type: typeof NodeTypes.LiquidBranch; name: 'catch'; markup: any } {
|
|
340
|
+
return node?.type === NodeTypes.LiquidBranch && (node as any).name === NamedTags.catch;
|
|
341
|
+
}
|
|
@@ -56,7 +56,7 @@ describe('Module: UnusedAssign', () => {
|
|
|
56
56
|
{{ usedVar }}
|
|
57
57
|
`;
|
|
58
58
|
|
|
59
|
-
expect(suggestions).to.
|
|
59
|
+
expect(suggestions).to.deep.equal([expectedFixedCode]);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('should not report unused assigns for things used in a capture tag', async () => {
|
|
@@ -51,14 +51,16 @@ describe('Module: UnusedDocParam', () => {
|
|
|
51
51
|
const offenses = await runLiquidCheck(UnusedDocParam, sourceCode);
|
|
52
52
|
const suggestions = applySuggestions(sourceCode, offenses[0]);
|
|
53
53
|
|
|
54
|
-
expect(suggestions).to.
|
|
54
|
+
expect(suggestions).to.deep.equal([
|
|
55
|
+
`
|
|
55
56
|
{% doc %}
|
|
56
57
|
@param param1 - Example param
|
|
57
58
|
|
|
58
59
|
{% enddoc %}
|
|
59
60
|
|
|
60
61
|
{{ param1 }}
|
|
61
|
-
|
|
62
|
+
`,
|
|
63
|
+
]);
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
LoopNamedTags.forEach((tag) => {
|
|
@@ -69,7 +69,7 @@ describe('Module: ValidDocParamTypes', () => {
|
|
|
69
69
|
expect(offenses).to.have.length(1);
|
|
70
70
|
const suggestions = applySuggestions(source, offenses[0]);
|
|
71
71
|
|
|
72
|
-
expect(suggestions).to.
|
|
72
|
+
expect(suggestions).to.deep.equal([`{% doc %} @param param1 - Example param {% enddoc %}`]);
|
|
73
73
|
}
|
|
74
74
|
});
|
|
75
75
|
});
|
|
@@ -34,7 +34,7 @@ describe('Module: ValidRenderPartialParamTypes', () => {
|
|
|
34
34
|
{ value: "'hello'", actualType: BasicParamTypes.String },
|
|
35
35
|
{ value: '123', actualType: BasicParamTypes.Number },
|
|
36
36
|
{ value: 'true', actualType: BasicParamTypes.Boolean },
|
|
37
|
-
{ value: 'empty', actualType: BasicParamTypes.
|
|
37
|
+
{ value: 'empty', actualType: BasicParamTypes.String },
|
|
38
38
|
],
|
|
39
39
|
},
|
|
40
40
|
];
|
|
@@ -142,6 +142,29 @@ describe('Module: ValidRenderPartialParamTypes', () => {
|
|
|
142
142
|
expect(offenses).toHaveLength(0);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
it('should not report null/nil as type mismatch for any type', async () => {
|
|
146
|
+
for (const type of ['string', 'number', 'object', 'boolean']) {
|
|
147
|
+
for (const literal of ['nil', 'null']) {
|
|
148
|
+
const sourceCode = `{% render 'card', param: ${literal} %}`;
|
|
149
|
+
const offenses = await runLiquidCheck(
|
|
150
|
+
ValidRenderPartialArgumentTypes,
|
|
151
|
+
sourceCode,
|
|
152
|
+
undefined,
|
|
153
|
+
{},
|
|
154
|
+
{
|
|
155
|
+
'app/views/partials/card.liquid': `
|
|
156
|
+
{% doc %}
|
|
157
|
+
@param {${type}} param - Description
|
|
158
|
+
{% enddoc %}
|
|
159
|
+
<div>{{ param }}</div>
|
|
160
|
+
`,
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
expect(offenses, `${literal} should be valid for ${type}`).toHaveLength(0);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
145
168
|
it('should not enforce unsupported types', async () => {
|
|
146
169
|
const sourceCode = `{% render 'card', title: 123 %}`;
|
|
147
170
|
const offenses = await runLiquidCheck(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
2
2
|
import { NodeTypes, RenderMarkup } from '@platformos/liquid-html-parser';
|
|
3
3
|
import { LiquidDocParameter } from '../../liquid-doc/liquidDoc';
|
|
4
|
-
import { inferArgumentType, isTypeCompatible } from '../../liquid-doc/utils';
|
|
4
|
+
import { inferArgumentType, isNullLiteral, isTypeCompatible } from '../../liquid-doc/utils';
|
|
5
5
|
import {
|
|
6
6
|
findTypeMismatchParams,
|
|
7
7
|
generateTypeMismatchSuggestions,
|
|
@@ -41,7 +41,8 @@ export const ValidRenderPartialArgumentTypes: LiquidCheckDefinition = {
|
|
|
41
41
|
if (
|
|
42
42
|
node.alias &&
|
|
43
43
|
node.variable?.name &&
|
|
44
|
-
node.variable.name.type !== NodeTypes.VariableLookup
|
|
44
|
+
node.variable.name.type !== NodeTypes.VariableLookup &&
|
|
45
|
+
!isNullLiteral(node.variable.name)
|
|
45
46
|
) {
|
|
46
47
|
const paramIsDefinedWithType = liquidDocParameters
|
|
47
48
|
.get(node.alias.value)
|
|
@@ -40,7 +40,7 @@ describe('Module: VariableName', () => {
|
|
|
40
40
|
|
|
41
41
|
const expectedFixedCode = `{% assign variable_name = "value" %}`;
|
|
42
42
|
|
|
43
|
-
expect(suggestions).to.
|
|
43
|
+
expect(suggestions).to.deep.equal([expectedFixedCode]);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it('should not report an error for variables starting with underscore', async () => {
|
package/src/context-utils.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
AbstractFileSystem,
|
|
4
4
|
FileTuple,
|
|
5
5
|
FileType,
|
|
6
|
+
RouteTable,
|
|
6
7
|
TranslationProvider,
|
|
7
8
|
UriString,
|
|
8
9
|
} from '@platformos/platformos-common';
|
|
@@ -131,6 +132,31 @@ function getDefaultTranslationsFromBuffer(app: App): Translations | undefined {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
export function makeGetRouteTable(
|
|
136
|
+
fs: AbstractFileSystem,
|
|
137
|
+
rootUri: string,
|
|
138
|
+
existingTable?: RouteTable,
|
|
139
|
+
): () => Promise<RouteTable> {
|
|
140
|
+
const table = existingTable ?? new RouteTable(fs);
|
|
141
|
+
let buildPromise: Promise<RouteTable> | null = null;
|
|
142
|
+
return () => {
|
|
143
|
+
if (!buildPromise) {
|
|
144
|
+
if (table.isBuilt()) {
|
|
145
|
+
buildPromise = Promise.resolve(table);
|
|
146
|
+
} else {
|
|
147
|
+
buildPromise = table
|
|
148
|
+
.build(URI.parse(rootUri))
|
|
149
|
+
.then(() => table)
|
|
150
|
+
.catch((err) => {
|
|
151
|
+
buildPromise = null;
|
|
152
|
+
throw err;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return buildPromise;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
function cached<T>(fn: () => Promise<T>): () => Promise<T>;
|
|
135
161
|
function cached<T>(fn: (...args: any[]) => Promise<T>): (...args: any[]) => Promise<T> {
|
|
136
162
|
let cachedPromise: Promise<T>;
|
|
@@ -145,7 +171,13 @@ export async function recursiveReadDirectory(
|
|
|
145
171
|
uri: string,
|
|
146
172
|
filter: (fileTuple: FileTuple) => boolean,
|
|
147
173
|
): Promise<UriString[]> {
|
|
148
|
-
|
|
174
|
+
let allFiles: FileTuple[];
|
|
175
|
+
try {
|
|
176
|
+
allFiles = await fs.readDirectory(uri);
|
|
177
|
+
} catch (err: any) {
|
|
178
|
+
if (err?.code === 'ENOENT') return [];
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
149
181
|
const files = allFiles.filter((ft) => !isIgnored(ft) && (isDirectory(ft) || filter(ft)));
|
|
150
182
|
|
|
151
183
|
const results = await Promise.all(
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
makeFileSize,
|
|
6
6
|
makeGetDefaultLocale,
|
|
7
7
|
makeGetDefaultTranslations,
|
|
8
|
+
makeGetRouteTable,
|
|
8
9
|
makeGetTranslationsForBase,
|
|
9
10
|
} from './context-utils';
|
|
10
11
|
import { createDisabledChecksModule } from './disabled-checks';
|
|
@@ -80,6 +81,7 @@ export * from './utils/object';
|
|
|
80
81
|
export * from './visitor';
|
|
81
82
|
export * from './liquid-doc/liquidDoc';
|
|
82
83
|
export * from './liquid-doc/utils';
|
|
84
|
+
export * from './url-helpers';
|
|
83
85
|
|
|
84
86
|
const defaultErrorHandler = (_error: Error): void => {
|
|
85
87
|
// Silently ignores errors by default.
|
|
@@ -101,6 +103,7 @@ export async function check(
|
|
|
101
103
|
getDefaultLocale: makeGetDefaultLocale(fs, rootUri),
|
|
102
104
|
getDefaultTranslations: makeGetDefaultTranslations(fs, app, rootUri),
|
|
103
105
|
getTranslationsForBase: makeGetTranslationsForBase(fs, app),
|
|
106
|
+
getRouteTable: makeGetRouteTable(fs, rootUri, injectedDependencies.routeTable),
|
|
104
107
|
};
|
|
105
108
|
|
|
106
109
|
const { DisabledChecksVisitor, isDisabled } = createDisabledChecksModule();
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
BasicParamTypes,
|
|
14
14
|
getDefaultValueForType,
|
|
15
15
|
inferArgumentType,
|
|
16
|
+
isNullLiteral,
|
|
16
17
|
isTypeCompatible,
|
|
17
18
|
} from './utils';
|
|
18
19
|
import { isLiquidString } from '../checks/utils';
|
|
@@ -133,6 +134,11 @@ export function findTypeMismatchParams(
|
|
|
133
134
|
continue;
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
// null/nil is compatible with any type — skip type checking
|
|
138
|
+
if (isNullLiteral(arg.value)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
136
142
|
const liquidDocParamDef = liquidDocParameters.get(arg.name);
|
|
137
143
|
if (liquidDocParamDef && liquidDocParamDef.type) {
|
|
138
144
|
const paramType = liquidDocParamDef.type.toLowerCase();
|
package/src/liquid-doc/utils.ts
CHANGED
|
@@ -18,6 +18,11 @@ export enum BasicParamTypes {
|
|
|
18
18
|
Object = 'object',
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Inferred type for null/nil literals — not a valid @param type, only used in type mismatch messages. */
|
|
22
|
+
export const InferredNull = 'null' as const;
|
|
23
|
+
|
|
24
|
+
export type InferredParamType = BasicParamTypes | typeof InferredNull;
|
|
25
|
+
|
|
21
26
|
export enum SupportedDocTagTypes {
|
|
22
27
|
Param = 'param',
|
|
23
28
|
Example = 'example',
|
|
@@ -44,7 +49,7 @@ export function getDefaultValueForType(type: string | null) {
|
|
|
44
49
|
/**
|
|
45
50
|
* Casts the value of a LiquidNamedArgument to a string representing the type of the value.
|
|
46
51
|
*/
|
|
47
|
-
export function inferArgumentType(arg: LiquidExpression | LiquidVariable):
|
|
52
|
+
export function inferArgumentType(arg: LiquidExpression | LiquidVariable): InferredParamType {
|
|
48
53
|
if (arg.type === NodeTypes.LiquidVariable) {
|
|
49
54
|
// A variable with filters — delegate to the base expression if there are no filters,
|
|
50
55
|
// otherwise we can't statically determine the filtered output type.
|
|
@@ -59,6 +64,8 @@ export function inferArgumentType(arg: LiquidExpression | LiquidVariable): Basic
|
|
|
59
64
|
case NodeTypes.Number:
|
|
60
65
|
return BasicParamTypes.Number;
|
|
61
66
|
case NodeTypes.LiquidLiteral:
|
|
67
|
+
if (arg.value === null) return InferredNull;
|
|
68
|
+
if (arg.value === '') return BasicParamTypes.String;
|
|
62
69
|
return BasicParamTypes.Boolean;
|
|
63
70
|
case NodeTypes.Range:
|
|
64
71
|
case NodeTypes.VariableLookup:
|
|
@@ -71,12 +78,29 @@ export function inferArgumentType(arg: LiquidExpression | LiquidVariable): Basic
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Checks if a LiquidExpression is a null/nil literal.
|
|
83
|
+
* null/nil is compatible with any type — it represents "no value".
|
|
84
|
+
*/
|
|
85
|
+
export function isNullLiteral(arg: LiquidExpression | LiquidVariable): boolean {
|
|
86
|
+
if (arg.type === NodeTypes.LiquidVariable) {
|
|
87
|
+
if (arg.filters.length > 0) return false;
|
|
88
|
+
const expr = arg.expression;
|
|
89
|
+
if (expr.type === NodeTypes.BooleanExpression) return false;
|
|
90
|
+
return isNullLiteral(expr);
|
|
91
|
+
}
|
|
92
|
+
if (arg.type === NodeTypes.LiquidLiteral) {
|
|
93
|
+
return arg.value === null;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
/**
|
|
75
99
|
* Checks if the provided argument type is compatible with the expected type.
|
|
76
100
|
* Makes certain types more permissive:
|
|
77
101
|
* - Boolean accepts any value, since everything is truthy / falsy in Liquid
|
|
78
102
|
*/
|
|
79
|
-
export function isTypeCompatible(expectedType: string, actualType:
|
|
103
|
+
export function isTypeCompatible(expectedType: string, actualType: InferredParamType): boolean {
|
|
80
104
|
const normalizedExpectedType = expectedType.toLowerCase();
|
|
81
105
|
|
|
82
106
|
if (normalizedExpectedType === BasicParamTypes.Boolean) {
|
package/src/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { LiquidHtmlNode, NodeTypes as LiquidHtmlNodeTypes } from '@platformos/li
|
|
|
2
2
|
|
|
3
3
|
import { Schema, Settings } from './types/schema-prop-factory';
|
|
4
4
|
|
|
5
|
-
import { AbstractFileSystem, UriString } from '@platformos/platformos-common';
|
|
5
|
+
import { AbstractFileSystem, RouteTable, UriString } from '@platformos/platformos-common';
|
|
6
6
|
import { JSONCorrector, StringCorrector } from './fixes';
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -355,6 +355,12 @@ export interface Dependencies {
|
|
|
355
355
|
* Returns an empty array if no files reference this file
|
|
356
356
|
*/
|
|
357
357
|
getReferences?: (uri: string) => Promise<Reference[]>;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Optional pre-built RouteTable. When provided (e.g. by the LSP),
|
|
361
|
+
* the check pipeline reuses it instead of building a new one per run.
|
|
362
|
+
*/
|
|
363
|
+
routeTable?: RouteTable;
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
export type ValidateJSON = (
|
|
@@ -377,6 +383,8 @@ export interface AugmentedDependencies extends Dependencies {
|
|
|
377
383
|
* Covers both `{base}/{locale}.yml` and `{base}/{locale}/*.yml`.
|
|
378
384
|
*/
|
|
379
385
|
getTranslationsForBase(translationBaseUri: string, locale: string): Promise<Translations>;
|
|
386
|
+
/** Lazily builds and returns a shared RouteTable for the current check run. */
|
|
387
|
+
getRouteTable(): Promise<RouteTable>;
|
|
380
388
|
}
|
|
381
389
|
|
|
382
390
|
type StaticContextProperties<T extends SourceCodeType> = T extends SourceCodeType
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
toLiquidHtmlAST,
|
|
4
|
+
NodeTypes,
|
|
5
|
+
HtmlElement,
|
|
6
|
+
LiquidTag,
|
|
7
|
+
LiquidTagAssign,
|
|
8
|
+
AssignMarkup,
|
|
9
|
+
LiquidHtmlNode,
|
|
10
|
+
} from '@platformos/liquid-html-parser';
|
|
11
|
+
import {
|
|
12
|
+
resolveAssignToUrlPattern,
|
|
13
|
+
extractUrlPattern,
|
|
14
|
+
isValuedAttrNode,
|
|
15
|
+
getAttrName,
|
|
16
|
+
ValuedAttrNode,
|
|
17
|
+
} from './url-helpers';
|
|
18
|
+
|
|
19
|
+
/** Parse a Liquid template and extract the first {% assign %} markup. */
|
|
20
|
+
function parseAssign(source: string): AssignMarkup {
|
|
21
|
+
const ast = toLiquidHtmlAST(source);
|
|
22
|
+
const assignTag = ast.children.find(
|
|
23
|
+
(n: LiquidHtmlNode) => n.type === NodeTypes.LiquidTag && (n as LiquidTag).name === 'assign',
|
|
24
|
+
) as LiquidTagAssign | undefined;
|
|
25
|
+
if (!assignTag) throw new Error('No assign tag found in: ' + source);
|
|
26
|
+
return assignTag.markup as AssignMarkup;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parse HTML with an <a> tag and return the href attribute node. */
|
|
30
|
+
function parseHrefAttr(source: string): ValuedAttrNode {
|
|
31
|
+
const ast = toLiquidHtmlAST(source);
|
|
32
|
+
const aTag = ast.children.find(
|
|
33
|
+
(n: LiquidHtmlNode) =>
|
|
34
|
+
n.type === NodeTypes.HtmlElement && (n as HtmlElement).name[0].type === NodeTypes.TextNode,
|
|
35
|
+
) as HtmlElement | undefined;
|
|
36
|
+
if (!aTag) throw new Error('No HTML element found in: ' + source);
|
|
37
|
+
const href = (aTag.attributes as LiquidHtmlNode[]).find(
|
|
38
|
+
(a) => isValuedAttrNode(a) && getAttrName(a) === 'href',
|
|
39
|
+
);
|
|
40
|
+
if (!href || !isValuedAttrNode(href)) throw new Error('No href attribute found in: ' + source);
|
|
41
|
+
return href;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('resolveAssignToUrlPattern', () => {
|
|
45
|
+
describe('string literal base', () => {
|
|
46
|
+
it('resolves a simple string literal', () => {
|
|
47
|
+
const markup = parseAssign('{% assign url = "/about" %}');
|
|
48
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/about');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('resolves a string with trailing slash', () => {
|
|
52
|
+
const markup = parseAssign('{% assign url = "/groups/" %}');
|
|
53
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/groups/');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('resolves a root path', () => {
|
|
57
|
+
const markup = parseAssign('{% assign url = "/" %}');
|
|
58
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('append filter', () => {
|
|
63
|
+
it('appends a string literal', () => {
|
|
64
|
+
const markup = parseAssign('{% assign url = "/groups" | append: "/edit" %}');
|
|
65
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/groups/edit');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('appends a variable as :_liquid_ placeholder', () => {
|
|
69
|
+
const markup = parseAssign('{% assign url = "/groups/" | append: group.id %}');
|
|
70
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/groups/:_liquid_');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('chains multiple append filters', () => {
|
|
74
|
+
const markup = parseAssign(
|
|
75
|
+
'{% assign url = "/groups/" | append: group.id | append: "/edit" %}',
|
|
76
|
+
);
|
|
77
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/groups/:_liquid_/edit');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('chains append with string and variable args', () => {
|
|
81
|
+
const markup = parseAssign(
|
|
82
|
+
'{% assign url = "/users/" | append: user.id | append: "/posts/" | append: post.id %}',
|
|
83
|
+
);
|
|
84
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/users/:_liquid_/posts/:_liquid_');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('prepend filter', () => {
|
|
89
|
+
it('prepends a string literal', () => {
|
|
90
|
+
const markup = parseAssign('{% assign url = "/edit" | prepend: "/groups" %}');
|
|
91
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/groups/edit');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('prepends a variable as :_liquid_ placeholder', () => {
|
|
95
|
+
const markup = parseAssign('{% assign url = "/edit" | prepend: group.id %}');
|
|
96
|
+
// Result is ":_liquid_/edit" — doesn't start with /, returns null
|
|
97
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('chains prepend filters', () => {
|
|
101
|
+
const markup = parseAssign(
|
|
102
|
+
'{% assign url = "/edit" | prepend: user.id | prepend: "/users/" %}',
|
|
103
|
+
);
|
|
104
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/users/:_liquid_/edit');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('mixed append and prepend', () => {
|
|
109
|
+
it('handles append then prepend', () => {
|
|
110
|
+
const markup = parseAssign('{% assign url = "/" | append: "edit" | prepend: "/groups" %}');
|
|
111
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/groups/edit');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('variable lookup base', () => {
|
|
116
|
+
it('resolves a variable base to :_liquid_', () => {
|
|
117
|
+
const markup = parseAssign('{% assign url = base_path %}');
|
|
118
|
+
// Result is ":_liquid_" — doesn't start with /, returns null
|
|
119
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('resolves a variable base with prepend to produce a valid URL', () => {
|
|
123
|
+
const markup = parseAssign('{% assign url = slug | prepend: "/" %}');
|
|
124
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/:_liquid_');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('resolves a variable base with append', () => {
|
|
128
|
+
const markup = parseAssign('{% assign url = base | append: "/edit" %}');
|
|
129
|
+
// Result is ":_liquid_/edit" — doesn't start with /, returns null
|
|
130
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('returns null for unsupported patterns', () => {
|
|
135
|
+
it('returns null for << operator (array push)', () => {
|
|
136
|
+
const markup = parseAssign('{% assign arr << "/item" %}');
|
|
137
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns null for non-append/prepend filters', () => {
|
|
141
|
+
const markup = parseAssign('{% assign url = "/ABOUT" | downcase %}');
|
|
142
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns null for unknown filter in chain', () => {
|
|
146
|
+
const markup = parseAssign('{% assign url = "/groups" | append: "/edit" | strip %}');
|
|
147
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns null when value does not start with /', () => {
|
|
151
|
+
const markup = parseAssign('{% assign url = "about" %}');
|
|
152
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns null for number literal base', () => {
|
|
156
|
+
const markup = parseAssign('{% assign num = 42 %}');
|
|
157
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns null when :_liquid_ is mixed with text in a segment', () => {
|
|
161
|
+
const markup = parseAssign('{% assign url = "/groups/group-" | append: group.id %}');
|
|
162
|
+
// Result would be "/groups/group-:_liquid_" — mixed segment
|
|
163
|
+
expect(resolveAssignToUrlPattern(markup)).toBe(null);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('query string and fragment stripping', () => {
|
|
168
|
+
it('strips query string from resolved URL', () => {
|
|
169
|
+
const markup = parseAssign('{% assign url = "/search?q=test" %}');
|
|
170
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/search');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('strips fragment from resolved URL', () => {
|
|
174
|
+
const markup = parseAssign('{% assign url = "/page#section" %}');
|
|
175
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/page');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('strips both query string and fragment', () => {
|
|
179
|
+
const markup = parseAssign('{% assign url = "/page?q=1#top" %}');
|
|
180
|
+
expect(resolveAssignToUrlPattern(markup)).toBe('/page');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('extractUrlPattern with variableMap', () => {
|
|
186
|
+
it('resolves a single {{ var }} from variableMap', () => {
|
|
187
|
+
const variableMap = new Map([['url', '/about']]);
|
|
188
|
+
const attr = parseHrefAttr('<a href="{{ url }}">link</a>');
|
|
189
|
+
expect(extractUrlPattern(attr, variableMap)).toBe('/about');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('resolves a tracked variable with :_liquid_ segments', () => {
|
|
193
|
+
const variableMap = new Map([['edit_url', '/users/:_liquid_/edit']]);
|
|
194
|
+
const attr = parseHrefAttr('<a href="{{ edit_url }}">edit</a>');
|
|
195
|
+
expect(extractUrlPattern(attr, variableMap)).toBe('/users/:_liquid_/edit');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('falls back to :_liquid_ for untracked variables', () => {
|
|
199
|
+
const variableMap = new Map<string, string>();
|
|
200
|
+
const attr = parseHrefAttr('<a href="{{ unknown_var }}">link</a>');
|
|
201
|
+
// Single dynamic variable with no static text → fully dynamic → null
|
|
202
|
+
expect(extractUrlPattern(attr, variableMap)).toBe(null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not resolve variables with filters', () => {
|
|
206
|
+
const variableMap = new Map([['url', '/about']]);
|
|
207
|
+
const attr = parseHrefAttr('<a href="{{ url | escape }}">link</a>');
|
|
208
|
+
// Variable has a filter → not a simple variable → falls through to normal logic → fully dynamic
|
|
209
|
+
expect(extractUrlPattern(attr, variableMap)).toBe(null);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('does not resolve variables with lookups (e.g. url.path)', () => {
|
|
213
|
+
const variableMap = new Map([['url', '/about']]);
|
|
214
|
+
const attr = parseHrefAttr('<a href="{{ url.path }}">link</a>');
|
|
215
|
+
// Variable has lookups → not a simple variable → falls through → fully dynamic
|
|
216
|
+
expect(extractUrlPattern(attr, variableMap)).toBe(null);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('does not resolve when attr has multiple nodes (mixed static + variable)', () => {
|
|
220
|
+
const variableMap = new Map([['slug', 'about']]);
|
|
221
|
+
const attr = parseHrefAttr('<a href="/{{ slug }}">link</a>');
|
|
222
|
+
// attr.value.length > 1, so variableMap lookup is skipped; normal extraction applies
|
|
223
|
+
expect(extractUrlPattern(attr, variableMap)).toBe('/:_liquid_');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('works without variableMap (backward compatible)', () => {
|
|
227
|
+
const attr = parseHrefAttr('<a href="/about">link</a>');
|
|
228
|
+
expect(extractUrlPattern(attr)).toBe('/about');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('works with empty variableMap', () => {
|
|
232
|
+
const attr = parseHrefAttr('<a href="/about">link</a>');
|
|
233
|
+
expect(extractUrlPattern(attr, new Map())).toBe('/about');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('resolves a tracked simple variable from variableMap', () => {
|
|
237
|
+
const variableMap = new Map([['url', '/about']]);
|
|
238
|
+
const attr = parseHrefAttr('<a href="{{ url }}">link</a>');
|
|
239
|
+
expect(extractUrlPattern(attr, variableMap)).toBe('/about');
|
|
240
|
+
});
|
|
241
|
+
});
|