td-ai-tools 1.2.9 → 1.2.10
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/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/shopify-lint/SKILL.md +32 -14
- package/skills/shopify-lint/eslint-plugin-theory/README.md +198 -0
- package/skills/shopify-lint/eslint-plugin-theory/bin/lint-js.mjs +126 -0
- package/skills/shopify-lint/eslint-plugin-theory/eslint.config.mjs +4 -0
- package/skills/shopify-lint/eslint-plugin-theory/package-lock.json +2643 -0
- package/skills/shopify-lint/eslint-plugin-theory/package.json +46 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/config.ts +98 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/index.test.ts +79 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/index.ts +50 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/integration.test.ts +112 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.test.ts +195 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.ts +126 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/processors/liquid.test.ts +32 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/processors/liquid.ts +32 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/centralize-selectors.test.ts +42 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/centralize-selectors.ts +71 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/disconnected-callback-cleanup.test.ts +126 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/disconnected-callback-cleanup.ts +206 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/guarded-custom-element-define.test.ts +67 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/guarded-custom-element-define.ts +142 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/no-class-selectors.test.ts +69 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/no-class-selectors.ts +91 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/no-unapproved-imports.test.ts +68 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/no-unapproved-imports.ts +97 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/optional.ts +181 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/require-jsdoc.test.ts +36 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/rules/require-jsdoc.ts +85 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/utils/ast.ts +82 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/utils/selectors.ts +105 -0
- package/skills/shopify-lint/eslint-plugin-theory/tsconfig.build.json +9 -0
- package/skills/shopify-lint/eslint-plugin-theory/tsconfig.json +14 -0
- package/skills/shopify-lint/eslint-plugin-theory/vitest.config.ts +13 -0
- package/skills/shopify-lint/hooks/claude-stop.sh +10 -5
- package/skills/shopify-lint/hooks/codex-stop.sh +10 -5
- package/skills/shopify-lint/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/scripts/setup.sh +13 -2
- package/skills/shopify-lint/scripts/shopify_lint.py +203 -4
- package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/tests/test_shopify_lint.py +449 -0
- package/skills/visual-regression/scripts/__pycache__/visual_regression.cpython-312.pyc +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-theory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Theory Digital's custom ESLint rules for Shopify theme JavaScript",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./config": "./dist/config.js"
|
|
11
|
+
},
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"bin",
|
|
16
|
+
"eslint.config.mjs",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.build.json",
|
|
21
|
+
"prepack": "npm run build",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"shopify",
|
|
27
|
+
"eslint",
|
|
28
|
+
"eslintplugin",
|
|
29
|
+
"liquid",
|
|
30
|
+
"lint"
|
|
31
|
+
],
|
|
32
|
+
"license": "UNLICENSED",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@eslint/js": "9.39.1",
|
|
38
|
+
"eslint": "9.39.1",
|
|
39
|
+
"globals": "16.5.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "24.0.15",
|
|
43
|
+
"typescript": "5.8.3",
|
|
44
|
+
"vitest": "3.2.7"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import globals from 'globals';
|
|
3
|
+
import type { Linter } from 'eslint';
|
|
4
|
+
|
|
5
|
+
import { plugin } from './index.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Globals a Shopify storefront provides that are not part of the browser
|
|
9
|
+
* environment. Themes that expose more can extend this through a config passed
|
|
10
|
+
* with `SHOPIFY_LINT_ESLINT_CONFIG`.
|
|
11
|
+
*/
|
|
12
|
+
const SHOPIFY_GLOBALS: Linter.Globals = {
|
|
13
|
+
Shopify: 'readonly',
|
|
14
|
+
Swiper: 'readonly',
|
|
15
|
+
theme: 'readonly',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Severities for the bundled Theory rules.
|
|
20
|
+
*
|
|
21
|
+
* Rules that are purely syntactic, and whose violation is a real defect, are
|
|
22
|
+
* errors and so fail the branch lint at the default `--fail-level error`. The
|
|
23
|
+
* convention rules are warnings: they are reported but advisory, matching how
|
|
24
|
+
* the bundled Theme Check rules behave today.
|
|
25
|
+
*/
|
|
26
|
+
const THEORY_RULES: Linter.RulesRecord = {
|
|
27
|
+
'theory/guarded-custom-element-define': 'error',
|
|
28
|
+
'theory/no-unapproved-imports': 'error',
|
|
29
|
+
'theory/disconnected-callback-cleanup': 'warn',
|
|
30
|
+
'theory/no-class-selectors': 'warn',
|
|
31
|
+
'theory/centralize-selectors': 'warn',
|
|
32
|
+
'theory/require-jsdoc': 'warn',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Rules shipped but disabled by default; documented as opt-in. */
|
|
36
|
+
const THEORY_OPTIONAL_RULES: Linter.RulesRecord = {
|
|
37
|
+
'theory/respect-reduced-motion': 'off',
|
|
38
|
+
'theory/aria-expanded-sync': 'off',
|
|
39
|
+
'theory/escape-to-close': 'off',
|
|
40
|
+
'theory/require-block-select-events': 'off',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface ConfigOptions {
|
|
44
|
+
/**
|
|
45
|
+
* How to parse standalone `.js` files. Theme assets are usually plain
|
|
46
|
+
* scripts, so the runner sniffs for top-level `import`/`export` and only
|
|
47
|
+
* uses `'module'` when it finds one.
|
|
48
|
+
*/
|
|
49
|
+
sourceType?: 'script' | 'module';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createConfig(options: ConfigOptions = {}): Linter.Config[] {
|
|
53
|
+
const sourceType = options.sourceType ?? 'script';
|
|
54
|
+
const browserGlobals: Linter.Globals = { ...globals.browser, ...SHOPIFY_GLOBALS };
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
ignores: ['**/node_modules/**', '**/*.min.js', '**/*.bundle.js'],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
files: ['**/*.js', '**/*.mjs'],
|
|
62
|
+
languageOptions: {
|
|
63
|
+
ecmaVersion: 2022,
|
|
64
|
+
sourceType,
|
|
65
|
+
globals: browserGlobals,
|
|
66
|
+
},
|
|
67
|
+
plugins: { theory: plugin },
|
|
68
|
+
rules: {
|
|
69
|
+
...js.configs.recommended.rules,
|
|
70
|
+
'no-empty': ['warn', { allowEmptyCatch: true }],
|
|
71
|
+
...THEORY_OPTIONAL_RULES,
|
|
72
|
+
...THEORY_RULES,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// JavaScript extracted from a `{% javascript %}` block. Shopify
|
|
77
|
+
// concatenates every theme block into one bundle, so identifiers defined
|
|
78
|
+
// in another file are legitimately global here.
|
|
79
|
+
files: ['**/*.liquid/*.js'],
|
|
80
|
+
languageOptions: {
|
|
81
|
+
ecmaVersion: 2022,
|
|
82
|
+
sourceType: 'script',
|
|
83
|
+
globals: browserGlobals,
|
|
84
|
+
},
|
|
85
|
+
rules: {
|
|
86
|
+
'no-undef': 'off',
|
|
87
|
+
'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
files: ['**/*.liquid'],
|
|
92
|
+
plugins: { theory: plugin },
|
|
93
|
+
processor: 'theory/liquid',
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default createConfig();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { plugin, rules } from './index.js';
|
|
4
|
+
import { createConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_ON = [
|
|
7
|
+
'guarded-custom-element-define',
|
|
8
|
+
'no-unapproved-imports',
|
|
9
|
+
'disconnected-callback-cleanup',
|
|
10
|
+
'no-class-selectors',
|
|
11
|
+
'centralize-selectors',
|
|
12
|
+
'require-jsdoc',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const DEFAULT_OFF = [
|
|
16
|
+
'respect-reduced-motion',
|
|
17
|
+
'aria-expanded-sync',
|
|
18
|
+
'escape-to-close',
|
|
19
|
+
'require-block-select-events',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe('plugin registry', () => {
|
|
23
|
+
it('registers every rule exactly once', () => {
|
|
24
|
+
expect(Object.keys(rules).sort()).toEqual([...DEFAULT_ON, ...DEFAULT_OFF].sort());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('exposes the liquid processor', () => {
|
|
28
|
+
expect(plugin.processors?.liquid).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('gives every rule a description and a schema', () => {
|
|
32
|
+
for (const [name, rule] of Object.entries(rules)) {
|
|
33
|
+
expect(rule.meta?.docs?.description, name).toBeTruthy();
|
|
34
|
+
expect(rule.meta?.schema, name).toBeDefined();
|
|
35
|
+
expect(Object.keys(rule.meta?.messages ?? {}).length, name).toBeGreaterThan(0);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('default config', () => {
|
|
41
|
+
const config = createConfig();
|
|
42
|
+
const jsBlock = config.find((entry) => entry.files?.includes('**/*.js'));
|
|
43
|
+
const configured = (jsBlock?.rules ?? {}) as Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
it('configures a severity for every registered rule', () => {
|
|
46
|
+
for (const name of Object.keys(rules)) {
|
|
47
|
+
expect(configured[`theory/${name}`], name).toBeDefined();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('enables the high-confidence rules as errors', () => {
|
|
52
|
+
expect(configured['theory/guarded-custom-element-define']).toBe('error');
|
|
53
|
+
expect(configured['theory/no-unapproved-imports']).toBe('error');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps the convention rules advisory', () => {
|
|
57
|
+
expect(configured['theory/disconnected-callback-cleanup']).toBe('warn');
|
|
58
|
+
expect(configured['theory/no-class-selectors']).toBe('warn');
|
|
59
|
+
expect(configured['theory/centralize-selectors']).toBe('warn');
|
|
60
|
+
expect(configured['theory/require-jsdoc']).toBe('warn');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ships the heuristic rules disabled', () => {
|
|
64
|
+
for (const name of DEFAULT_OFF) {
|
|
65
|
+
expect(configured[`theory/${name}`], name).toBe('off');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('turns no-undef off only for Liquid blocks', () => {
|
|
70
|
+
const liquidBlock = config.find((entry) => entry.files?.includes('**/*.liquid/*.js'));
|
|
71
|
+
expect(liquidBlock?.rules?.['no-undef']).toBe('off');
|
|
72
|
+
expect(configured['no-undef']).toBe('error');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('wires the processor onto .liquid files', () => {
|
|
76
|
+
const liquidFiles = config.find((entry) => entry.files?.includes('**/*.liquid'));
|
|
77
|
+
expect(liquidFiles?.processor).toBe('theory/liquid');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ESLint, Rule } from 'eslint';
|
|
2
|
+
|
|
3
|
+
import { liquid } from './processors/liquid.js';
|
|
4
|
+
import { centralizeSelectors } from './rules/centralize-selectors.js';
|
|
5
|
+
import { disconnectedCallbackCleanup } from './rules/disconnected-callback-cleanup.js';
|
|
6
|
+
import { guardedCustomElementDefine } from './rules/guarded-custom-element-define.js';
|
|
7
|
+
import { noClassSelectors } from './rules/no-class-selectors.js';
|
|
8
|
+
import { noUnapprovedImports } from './rules/no-unapproved-imports.js';
|
|
9
|
+
import { requireJsdoc } from './rules/require-jsdoc.js';
|
|
10
|
+
import {
|
|
11
|
+
ariaExpandedSync,
|
|
12
|
+
escapeToClose,
|
|
13
|
+
requireBlockSelectEvents,
|
|
14
|
+
respectReducedMotion,
|
|
15
|
+
} from './rules/optional.js';
|
|
16
|
+
|
|
17
|
+
export const rules: Record<string, Rule.RuleModule> = {
|
|
18
|
+
// Enabled by default.
|
|
19
|
+
'guarded-custom-element-define': guardedCustomElementDefine,
|
|
20
|
+
'no-unapproved-imports': noUnapprovedImports,
|
|
21
|
+
'disconnected-callback-cleanup': disconnectedCallbackCleanup,
|
|
22
|
+
'no-class-selectors': noClassSelectors,
|
|
23
|
+
'centralize-selectors': centralizeSelectors,
|
|
24
|
+
'require-jsdoc': requireJsdoc,
|
|
25
|
+
// Shipped disabled; see README.
|
|
26
|
+
'respect-reduced-motion': respectReducedMotion,
|
|
27
|
+
'aria-expanded-sync': ariaExpandedSync,
|
|
28
|
+
'escape-to-close': escapeToClose,
|
|
29
|
+
'require-block-select-events': requireBlockSelectEvents,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const plugin: ESLint.Plugin = {
|
|
33
|
+
meta: {
|
|
34
|
+
name: 'eslint-plugin-theory',
|
|
35
|
+
version: '0.1.0',
|
|
36
|
+
},
|
|
37
|
+
rules,
|
|
38
|
+
processors: {
|
|
39
|
+
liquid,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export { liquid };
|
|
44
|
+
export { maskLiquid } from './liquid/mask.js';
|
|
45
|
+
|
|
46
|
+
// `createConfig` deliberately lives in ./config.js and is not re-exported
|
|
47
|
+
// here: config.ts imports this module for the plugin object, and re-exporting
|
|
48
|
+
// it would make that a cycle.
|
|
49
|
+
|
|
50
|
+
export default plugin;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ESLint } from 'eslint';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
function lint(source: string, filePath: string) {
|
|
7
|
+
const eslint = new ESLint({
|
|
8
|
+
cwd: process.cwd(),
|
|
9
|
+
overrideConfigFile: true,
|
|
10
|
+
overrideConfig: createConfig({ sourceType: 'script' }),
|
|
11
|
+
errorOnUnmatchedPattern: false,
|
|
12
|
+
warnIgnored: false,
|
|
13
|
+
});
|
|
14
|
+
return eslint.lintText(source, { filePath });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds a source from an array of lines so the expected line number is
|
|
19
|
+
* derived rather than hand-counted.
|
|
20
|
+
*/
|
|
21
|
+
function sourceWith(lines: string[]) {
|
|
22
|
+
return { source: `${lines.join('\n')}\n`, lineOf: (needle: string) => lines.findIndex((line) => line.includes(needle)) + 1 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('linting a .liquid file end to end', () => {
|
|
26
|
+
it('reports offenses at their real line in the .liquid file', async () => {
|
|
27
|
+
const { source, lineOf } = sourceWith([
|
|
28
|
+
'<div class="td-hero">',
|
|
29
|
+
' <h2>{{ section.settings.heading }}</h2>',
|
|
30
|
+
'</div>',
|
|
31
|
+
'',
|
|
32
|
+
'{% javascript %}',
|
|
33
|
+
' const cfg = {{ section.settings.cfg | json }};',
|
|
34
|
+
' {% if section.settings.debug %}',
|
|
35
|
+
' console.log(cfg);',
|
|
36
|
+
' {% endif %}',
|
|
37
|
+
' const dupe = { a: 1, a: 2 };',
|
|
38
|
+
'{% endjavascript %}',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const [result] = await lint(source, 'sections/td-hero.liquid');
|
|
42
|
+
const dupeKeys = result.messages.filter((message) => message.ruleId === 'no-dupe-keys');
|
|
43
|
+
|
|
44
|
+
expect(dupeKeys).toHaveLength(1);
|
|
45
|
+
expect(dupeKeys[0]?.line).toBe(lineOf('a: 2'));
|
|
46
|
+
expect(source.split('\n')[dupeKeys[0]!.line - 1]).toContain('a: 2');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('maps offenses in a second block, after Liquid interpolation', async () => {
|
|
50
|
+
const { source, lineOf } = sourceWith([
|
|
51
|
+
'{% javascript %}',
|
|
52
|
+
' const first = {{ section.settings.a | json }};',
|
|
53
|
+
'{% endjavascript %}',
|
|
54
|
+
'',
|
|
55
|
+
'<p>{{ product.title }}</p>',
|
|
56
|
+
'',
|
|
57
|
+
'{% javascript %}',
|
|
58
|
+
' {% for block in section.blocks %}',
|
|
59
|
+
' const second = { b: 1, b: 2 };',
|
|
60
|
+
' {% endfor %}',
|
|
61
|
+
'{% endjavascript %}',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const [result] = await lint(source, 'sections/td-multi.liquid');
|
|
65
|
+
const dupeKeys = result.messages.filter((message) => message.ruleId === 'no-dupe-keys');
|
|
66
|
+
|
|
67
|
+
expect(dupeKeys).toHaveLength(1);
|
|
68
|
+
expect(dupeKeys[0]?.line).toBe(lineOf('b: 2'));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports the correct column, not just the line', async () => {
|
|
72
|
+
const source = '{% javascript %}\nconst dupe = { a: 1, a: 2 };\n{% endjavascript %}\n';
|
|
73
|
+
const [result] = await lint(source, 'sections/td-column.liquid');
|
|
74
|
+
const [message] = result.messages.filter((entry) => entry.ruleId === 'no-dupe-keys');
|
|
75
|
+
|
|
76
|
+
// `a: 2` starts at index 21 of line 2, so column 22.
|
|
77
|
+
expect(message?.line).toBe(2);
|
|
78
|
+
expect(message?.column).toBe(22);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not fire no-undef on Liquid-interpolated values', async () => {
|
|
82
|
+
const source = [
|
|
83
|
+
'{% javascript %}',
|
|
84
|
+
' const total = {{ cart.item_count | json }};',
|
|
85
|
+
' const name = "{{ product.title }}";',
|
|
86
|
+
' render(total, name);',
|
|
87
|
+
'{% endjavascript %}',
|
|
88
|
+
'',
|
|
89
|
+
].join('\n');
|
|
90
|
+
|
|
91
|
+
const [result] = await lint(source, 'sections/td-interp.liquid');
|
|
92
|
+
expect(result.messages.filter((message) => message.fatal)).toEqual([]);
|
|
93
|
+
expect(result.messages.filter((message) => message.ruleId === 'no-undef')).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('ignores markup outside the javascript block', async () => {
|
|
97
|
+
const source = '<script>var broken = ;</script>\n<div>{{ x }}</div>\n';
|
|
98
|
+
const [result] = await lint(source, 'sections/td-markup.liquid');
|
|
99
|
+
expect(result.messages).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('still lints a standalone .js file with no-undef enabled', async () => {
|
|
103
|
+
const [result] = await lint('missingHelper();\n', 'assets/td-scratch.js');
|
|
104
|
+
expect(result.messages.map((message) => message.ruleId)).toContain('no-undef');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('allows browser and Shopify globals in a standalone .js file', async () => {
|
|
108
|
+
const source = 'document.addEventListener("click", () => { Shopify.doThing(); });\n';
|
|
109
|
+
const [result] = await lint(source, 'assets/td-globals.js');
|
|
110
|
+
expect(result.messages.filter((message) => message.ruleId === 'no-undef')).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { maskLiquid } from './mask.js';
|
|
4
|
+
|
|
5
|
+
/** Realistic Liquid sources, used to assert the position-preserving invariants. */
|
|
6
|
+
const SAMPLES: Array<[string, string]> = [
|
|
7
|
+
[
|
|
8
|
+
'simple block',
|
|
9
|
+
`<div class="td-hero">Hello</div>
|
|
10
|
+
{% javascript %}
|
|
11
|
+
console.log('hi');
|
|
12
|
+
{% endjavascript %}
|
|
13
|
+
`,
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
'output interpolation',
|
|
17
|
+
`{% javascript %}
|
|
18
|
+
const config = {{ section.settings.config | json }};
|
|
19
|
+
start(config);
|
|
20
|
+
{% endjavascript %}
|
|
21
|
+
`,
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
'whitespace control',
|
|
25
|
+
`{%- javascript -%}
|
|
26
|
+
const flag = {{- section.settings.flag | json -}};
|
|
27
|
+
{%- endjavascript -%}
|
|
28
|
+
`,
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
'control flow inside the block',
|
|
32
|
+
`{% javascript %}
|
|
33
|
+
{% if section.settings.autoplay %}
|
|
34
|
+
start();
|
|
35
|
+
{% endif %}
|
|
36
|
+
{% endjavascript %}
|
|
37
|
+
`,
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
'comment tag inside the block',
|
|
41
|
+
`{% javascript %}
|
|
42
|
+
{% comment %} {{ not.real }} {% endcomment %}
|
|
43
|
+
run();
|
|
44
|
+
{% endjavascript %}
|
|
45
|
+
`,
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
'raw tag inside the block',
|
|
49
|
+
`{% javascript %}
|
|
50
|
+
{% raw %}{{ literal }}{% endraw %}
|
|
51
|
+
run();
|
|
52
|
+
{% endjavascript %}
|
|
53
|
+
`,
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
'multiple blocks',
|
|
57
|
+
`{% javascript %}
|
|
58
|
+
const a = 1;
|
|
59
|
+
{% endjavascript %}
|
|
60
|
+
<p>{{ product.title }}</p>
|
|
61
|
+
{% javascript %}
|
|
62
|
+
const b = a + 1;
|
|
63
|
+
{% endjavascript %}
|
|
64
|
+
`,
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
'multi-line output tag',
|
|
68
|
+
`{% javascript %}
|
|
69
|
+
const data = {{ section.settings.data
|
|
70
|
+
| json }};
|
|
71
|
+
{% endjavascript %}
|
|
72
|
+
`,
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
'liquid tag block',
|
|
76
|
+
`{% javascript %}
|
|
77
|
+
{% liquid
|
|
78
|
+
assign x = 1
|
|
79
|
+
assign y = 2
|
|
80
|
+
%}
|
|
81
|
+
run();
|
|
82
|
+
{% endjavascript %}
|
|
83
|
+
`,
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
'no javascript block',
|
|
87
|
+
`<div>{{ product.title }}</div>
|
|
88
|
+
{% schema %}{}{% endschema %}
|
|
89
|
+
`,
|
|
90
|
+
],
|
|
91
|
+
[
|
|
92
|
+
'non-BMP characters outside the block',
|
|
93
|
+
`<p>🎉 launch</p>
|
|
94
|
+
{% javascript %}
|
|
95
|
+
run();
|
|
96
|
+
{% endjavascript %}
|
|
97
|
+
`,
|
|
98
|
+
],
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
function newlineIndices(value: string): number[] {
|
|
102
|
+
const indices: number[] = [];
|
|
103
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
104
|
+
if (value[index] === '\n') {
|
|
105
|
+
indices.push(index);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return indices;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('maskLiquid invariants', () => {
|
|
112
|
+
it.each(SAMPLES)('preserves every position for %s', (_label, source) => {
|
|
113
|
+
const masked = maskLiquid(source);
|
|
114
|
+
if (masked === null) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
expect(masked).toHaveLength(source.length);
|
|
118
|
+
expect(newlineIndices(masked)).toEqual(newlineIndices(source));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each(SAMPLES)('only ever blanks or substitutes for %s', (_label, source) => {
|
|
122
|
+
const masked = maskLiquid(source);
|
|
123
|
+
if (masked === null) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
127
|
+
const original = source[index];
|
|
128
|
+
const result = masked[index];
|
|
129
|
+
expect(result === original || result === ' ' || result === '0').toBe(true);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('maskLiquid', () => {
|
|
135
|
+
it('returns null when there is no javascript block', () => {
|
|
136
|
+
expect(maskLiquid('<div>{{ product.title }}</div>')).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns null for an unterminated javascript block', () => {
|
|
140
|
+
expect(maskLiquid('{% javascript %}\n run();\n')).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('blanks everything outside the block, including the tags themselves', () => {
|
|
144
|
+
const masked = maskLiquid('<p>hi</p>{% javascript %}run();{% endjavascript %}<p>bye</p>');
|
|
145
|
+
// '<p>hi</p>' (9) + '{% javascript %}' (16), then the body, then
|
|
146
|
+
// '{% endjavascript %}' (19) + '<p>bye</p>' (10).
|
|
147
|
+
expect(masked).toBe(`${' '.repeat(25)}run();${' '.repeat(29)}`);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('substitutes a Liquid output with a single 0 followed by spaces', () => {
|
|
151
|
+
const masked = maskLiquid('{% javascript %}const x = {{ a.b }};{% endjavascript %}');
|
|
152
|
+
// `{{ a.b }}` is nine characters: `0` plus eight spaces.
|
|
153
|
+
expect(masked).toContain(`const x = 0${' '.repeat(8)};`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps a multi-line output tag on its original lines', () => {
|
|
157
|
+
const source = '{% javascript %}\nconst x = {{ a\n | json }};\n{% endjavascript %}';
|
|
158
|
+
const masked = maskLiquid(source) ?? '';
|
|
159
|
+
const lines = masked.split('\n');
|
|
160
|
+
expect(lines[1]).toBe('const x = 0 ');
|
|
161
|
+
// ` | json }}` is the eleven-character tail of the output tag.
|
|
162
|
+
expect(lines[2]).toBe(`${' '.repeat(11)};`);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('blanks a comment body rather than substituting the output inside it', () => {
|
|
166
|
+
const masked = maskLiquid('{% javascript %}{% comment %}{{ a }}{% endcomment %}run();{% endjavascript %}');
|
|
167
|
+
expect(masked).not.toContain('0');
|
|
168
|
+
expect(masked).toContain('run();');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('blanks a raw body rather than substituting the output inside it', () => {
|
|
172
|
+
const masked = maskLiquid('{% javascript %}{% raw %}{{ a }}{% endraw %}run();{% endjavascript %}');
|
|
173
|
+
expect(masked).not.toContain('0');
|
|
174
|
+
expect(masked).toContain('run();');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('leaves a nested JavaScript block alone', () => {
|
|
178
|
+
const source = '{% javascript %}if (a) {{ b(); }}{% endjavascript %}';
|
|
179
|
+
const masked = maskLiquid(source);
|
|
180
|
+
expect(masked).toContain('if (a) {{ b(); }}');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps content from every block in a multi-block file', () => {
|
|
184
|
+
const source = '{% javascript %}const a = 1;{% endjavascript %}\n{% javascript %}const b = 2;{% endjavascript %}';
|
|
185
|
+
const masked = maskLiquid(source) ?? '';
|
|
186
|
+
expect(masked).toContain('const a = 1;');
|
|
187
|
+
expect(masked).toContain('const b = 2;');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('blanks Liquid control flow inside the block', () => {
|
|
191
|
+
const masked = maskLiquid('{% javascript %}{% if a %}run();{% endif %}{% endjavascript %}');
|
|
192
|
+
expect(masked).toContain('run();');
|
|
193
|
+
expect(masked).not.toContain('if a');
|
|
194
|
+
});
|
|
195
|
+
});
|