Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way.
- In a repository that holds more than one Shopify theme, a theme's `{% render %}`, `{% include %}` and `{% section %}` tags and the sections its JSON templates list now link only to that theme's own snippets and sections, which is where Shopify looks for them. Before, a section or snippet the theme doesn't have was linked to another theme's file of the same name, and a theme kept in a subfolder could link to the root theme's copy instead of its own. A reference the theme can't satisfy now links to nothing. Projects with a single theme are indexed as before. Re-index projects with several Shopify themes after upgrading.
- `codegraph sync` and the file watcher now link a Shopify section or snippet to the files that name it when it is added after them, or comes back after being deleted, whether they name it in a `{% render %}`, `{% include %}` or `{% section %}` tag or as a section `type` in a JSON template. Before, the link appeared only once a file naming it changed or the project was re-indexed, even in a repository with a single theme, so the new section or snippet looked unused and `codegraph affected` missed the files that use it. Re-index Shopify themes after upgrading to pick up links an earlier version missed.
- A Shopify JSON template or section group saved by the theme editor now links the sections it lists. The editor writes a comment at the top of every file it saves, and a file with that comment, or with a trailing comma, both of which Shopify accepts, linked none of its sections, so in an editor-saved theme most sections looked unused. A layout's `{% sections 'header-group' %}` tag now links to the section group it renders, each section a JSON file lists is linked from the line that names it rather than from the top of the file, and a `{% schema %}` written inside `{% comment %}` or `{% raw %}` is no longer indexed as the section's own. Re-index Shopify themes after upgrading. Thanks @Puma7 for the report and the pull request. (#2494)
- `codegraph sync` and the file watcher now link a route to the page or layout it loads lazily when that file is added after the router was indexed, comes back after being deleted, or gets the component the route renders in a later edit, whether or not the import names the file's extension: React Router's `lazy: () => import('./pages/Team')`, Vue Router's `component: () => import('@/views/Login')` and Angular's `loadComponent: () => import('./home/home.component')`. Before, the route stayed unlinked until the router file changed or the project was re-indexed, so the page looked unused and `codegraph affected` missed the tests that reach it through the router. Re-index after upgrading to pick up links an earlier version missed.
- A file named the way Google names its tests, like `wire_format_unittest.cc` in protocolbuffers/protobuf, `logging_unittest.cc` in google/glog or a Python `foo_unittest.py`, now counts as a test, as `wire_format_test.cc` already did. Before, it was taken for production code: search and `codegraph_explore` ranked it alongside the code it tests, `codegraph affected` never listed it, and calls in production code could link to a class or type a unittest declares for itself, as google/breakpad's `stack_frame_entries_.size()` did to a unittest's `StackHelper`. Re-index projects that have such files after upgrading.
- A file in a `unittests/` folder, where llvm/llvm-project and google/breakpad keep their unit tests, or in a folder named like a unittest, such as `dcheck_unittest/` in google/glog, now counts as a test whatever its own name says, like LLVM's `CountCopyAndMove.h` helper. Before, such a file was taken for production code unless its name said test, and none of them were kept apart from the program the way a `tests/` folder is: search and `codegraph_explore` ranked them alongside production code, `codegraph affected` missed them, and production code linked to a unit test's own declarations, as LLVM's `Ctx` and `OpInfo` variables did to ones its unit tests declare. A folder named just `unittest/`, like CPython's `Lib/unittest/`, is still production code. Re-index projects that have such folders after upgrading.
Expand Down
125 changes: 125 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5417,6 +5417,40 @@ import 'package:flutter/material.dart';
.toEqual([['snippets/live.liquid', 18], ['snippets/after.liquid', 20]]);
});

/* A layout renders a section group, the JSON file `sections/<group>.json`,
with the plural tag; only the singular one was read (#2494). */
it.each([
"{% sections 'header-group' %}",
'{%- sections "header-group" -%}',
"{% liquid\n sections 'header-group'\n%}",
])('reads a section group from %j', (code) => {
const result = extractFromSource('layout/theme.liquid', code);
expect(result.nodes.filter((n) => n.kind === 'import').map((n) => n.name)).toEqual(['header-group']);
expect(result.unresolvedReferences.map((r) => r.referenceName)).toEqual(['sections/header-group.json']);
});

it('keeps a section and a section group of the same name apart', () => {
const result = extractFromSource('layout/theme.liquid', "{% section 'header' %}{% sections 'header' %}");
expect(result.unresolvedReferences.map((r) => [r.referenceName, r.line, r.column]))
.toEqual([['sections/header.liquid', 1, 0], ['sections/header.json', 1, 22]]);
expect(result.nodes.filter((n) => n.kind === 'component').map((n) => n.qualifiedName))
.toEqual(['layout/theme.liquid::section:header', 'layout/theme.liquid::sections:header']);
expect(new Set(result.nodes.map((n) => n.id)).size).toBe(result.nodes.length);
});

it.each(['comment', 'raw'])('indexes no schema written inside %s', (tag) => {
// An example schema in a comment is not the section's, and a lone
// opening tag in one must not swallow the real schema after it (#2494).
const code = [
`{% ${tag} %}`, '{% schema %}', '{"name": "Ghost"}', '{% endschema %}', `{% end${tag} %}`,
`{%- ${tag} -%}{% schema %}{%- end${tag} -%}`,
'{% schema %}', '{"name": "Live"}', '{% endschema %}',
].join('\n');
const result = extractFromSource('sections/header.liquid', code);
expect(result.nodes.filter((n) => n.kind === 'constant').map((n) => [n.name, n.startLine]))
.toEqual([['Live', 7]]);
});

it('should extract multiple imports', () => {
const code = `
{% section 'header' %}
Expand Down Expand Up @@ -7751,6 +7785,97 @@ describe('Liquid Shopify JSON template section resolution', () => {
expect(cg.getFileDependencies('sections/header.liquid')).toEqual(['snippets/price.liquid']);
}, 60_000);

// The notice Shopify's theme editor writes at the top of every JSON template
// and section group it saves.
const editorNotice = [
'/*',
' * ------------------------------------------------------------',
' * IMPORTANT: The contents of this file are auto-generated.',
' * ------------------------------------------------------------',
' */',
];
const referencesFrom = (file: string): Array<[string | undefined, number | undefined, number | undefined]> => {
const fileNode = cg.getNodesInFile(file).find((n) => n.kind === 'file')!;
return cg.getOutgoingEdges(fileNode.id)
.filter((e) => e.kind === 'references')
.map((e): [string | undefined, number | undefined, number | undefined] => [cg.getNode(e.target)?.filePath, e.line, e.column])
.sort((a, b) => a[1]! - b[1]!);
};

it.each(['\n', '\r\n'])('links the sections an editor-saved template and section group list, where each `type` is (%j)', async (newline) => {
// JSON.parse rejects the editor's comment, so such a file linked none of
// its sections, and a file without one linked them at line 1 (#2494).
writeFiles({
'layout/theme.liquid': `{% sections 'header-group' %}{{ content_for_layout }}\n`,
'sections/header-group.json': [
...editorNotice, '{', ' "type": "header",', ' "name": "Header",', ' "sections": {',
' "bar": { "type": "announcement-bar" },', ' "header": {', ' "type": "header"', ' }',
' },', ' "order": ["bar", "header"]', '}',
].join(newline),
'sections/announcement-bar.liquid': `<div></div>\n`,
'sections/header.liquid': `<header></header>\n`,
'sections/main-product.liquid': `<div>{{ product.title }}</div>\n`,
'templates/product.json': [
...editorNotice, '{', ' "sections": {', ' "main": {', ' "type": "main-product",',
' "settings": { "note": "https://example.com/*not-a-comment*/ // nor this" }', ' },',
' "again": { "type": "main-product" }', ' },', ' "order": ["main", "again"]', '}',
].join(newline),
'templates/index.json': `{\n "sections": {\n "main": { "type": "header" }\n },\n "order": ["main"]\n}\n`,
});
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();

expect(referencesFrom('templates/product.json')).toEqual([['sections/main-product.liquid', 9, 14]]);
expect(referencesFrom('sections/header-group.json')).toEqual([
['sections/announcement-bar.liquid', 10, 21],
['sections/header.liquid', 12, 14],
]);
expect(referencesFrom('templates/index.json')).toEqual([['sections/header.liquid', 3, 22]]);
expect(referencesFrom('layout/theme.liquid')).toEqual([['sections/header-group.json', 1, 0]]);
});

it('reads a JSON template with trailing commas, which Shopify accepts in theme JSON', () => {
const text = '// a note\n{"sections": {"main": {"type": "main-product",}, "hero": {"type": "hero"},}, "order": ["main", "hero",],}';
expect(extractFromSource('templates/product.json', text, 'liquid').unresolvedReferences.map((r) => r.referenceName))
.toEqual(['sections/main-product.liquid', 'sections/hero.liquid']);
});

it.each([
['a missing brace', '{"sections": {"main": {"type": "main-product"}}'],
['an unclosed comment', '/* saved by the editor {"sections": {"main": {"type": "main-product"}}}'],
['a missing comma', '{"sections": {"main": {"type": "main-product"} "hero": {"type": "hero"}}}'],
])('links nothing from a JSON template with %s', (_case, text) => {
expect(extractFromSource('templates/product.json', text, 'liquid').unresolvedReferences).toEqual([]);
});

it("links a layout's section groups inside its own theme, and on to the sections they list", async () => {
// `{% sections 'header-group' %}` renders the theme's
// sections/header-group.json, whose sections render snippets (#2494).
writeFiles({
...severalThemes,
'layout/theme.liquid': `{% sections 'header-group' %}{{ content_for_layout }}\n`,
'sections/header-group.json': [...editorNotice, jsonTemplate('header')].join('\n'),
// This theme has no header group of its own, and the root theme's is not its.
'examples/tailwind/layout/theme.liquid': `{% liquid\n sections 'header-group'\n%}{{ content_for_layout }}\n`,
});
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();

const linksOf = (file: string): string[] => cg.getFileDependencies(file).sort();
expect(linksOf('layout/theme.liquid')).toEqual(['sections/header-group.json']);
expect(linksOf('sections/header-group.json')).toEqual(['sections/header.liquid']);
expect(linksOf('sections/header.liquid')).toEqual(['snippets/price.liquid']);
expect(linksOf('examples/tailwind/layout/theme.liquid')).toEqual([]);

// Once the theme has a header group of its own, sync links the layout to it.
writeFiles({ 'examples/tailwind/sections/header-group.json': [...editorNotice, jsonTemplate('header')].join('\n') });
await cg.sync();
expect(linksOf('examples/tailwind/layout/theme.liquid')).toEqual(['examples/tailwind/sections/header-group.json']);
expect(linksOf('examples/tailwind/sections/header-group.json')).toEqual(['examples/tailwind/sections/header.liquid']);
}, 60_000);

it('finds the theme a file is in from the theme folder it sits in', () => {
const markers = new Set(['layout/theme.liquid', 'examples/tailwind/config/settings_schema.json']);
const exists = (relativePath: string): boolean => markers.has(relativePath);
Expand Down
Loading