diff --git a/packages/liquid-html-parser/src/stage-1-cst.spec.ts b/packages/liquid-html-parser/src/stage-1-cst.spec.ts
index 9c55ddd..96f6ba0 100644
--- a/packages/liquid-html-parser/src/stage-1-cst.spec.ts
+++ b/packages/liquid-html-parser/src/stage-1-cst.spec.ts
@@ -2182,13 +2182,90 @@ describe('Unit: Stage 1 (CST)', () => {
});
it('should parse the theme_render_rc tag', () => {
- for (const { toCST, expectPath } of testCases) {
- cst = toCST(`{% theme_render_rc 'partial' %}`);
- expectPath(cst, '0.type').to.equal('LiquidTag');
- expectPath(cst, '0.name').to.equal('theme_render_rc');
- expectPath(cst, '0.markup.type').to.equal('RenderMarkup');
- expectPath(cst, '0.markup.partial.type').to.equal('String');
- }
+ [
+ {
+ expression: `'partial'`,
+ partialType: 'String',
+ alias: null,
+ renderVariableExpression: null,
+ namedArguments: [],
+ },
+ {
+ expression: `'partial' as foo`,
+ partialType: 'String',
+ alias: {
+ value: 'foo',
+ },
+ renderVariableExpression: null,
+ namedArguments: [],
+ },
+ {
+ expression: `'partial' with product as item`,
+ partialType: 'String',
+ alias: {
+ value: 'item',
+ },
+ renderVariableExpression: {
+ kind: 'with',
+ name: {
+ type: 'VariableLookup',
+ },
+ },
+ namedArguments: [],
+ },
+ {
+ expression: `'partial' for products as product`,
+ partialType: 'String',
+ alias: {
+ value: 'product',
+ },
+ renderVariableExpression: {
+ kind: 'for',
+ name: {
+ type: 'VariableLookup',
+ },
+ },
+ namedArguments: [],
+ },
+ {
+ expression: `'my/partial', key1: val1, key2: "hi"`,
+ partialType: 'String',
+ alias: null,
+ renderVariableExpression: null,
+ namedArguments: [
+ { name: 'key1', valueType: 'VariableLookup' },
+ { name: 'key2', valueType: 'String' },
+ ],
+ },
+ ].forEach(
+ ({ expression, partialType, renderVariableExpression, alias, namedArguments }) => {
+ for (const { toCST, expectPath } of testCases) {
+ cst = toCST(`{% theme_render_rc ${expression} -%}`);
+ expectPath(cst, '0.type').to.equal('LiquidTag');
+ expectPath(cst, '0.name').to.equal('theme_render_rc');
+ expectPath(cst, '0.markup.type').to.equal('RenderMarkup');
+ expectPath(cst, '0.markup.partial.type').to.equal(partialType);
+ if (renderVariableExpression) {
+ expectPath(cst, '0.markup.variable.type').to.equal('RenderVariableExpression');
+ expectPath(cst, '0.markup.variable.kind').to.equal(renderVariableExpression.kind);
+ expectPath(cst, '0.markup.variable.name.type').to.equal(
+ renderVariableExpression.name.type,
+ );
+ } else {
+ expectPath(cst, '0.markup.variable').to.equal(null);
+ }
+ expectPath(cst, '0.markup.alias.value').to.equal(alias?.value);
+ expectPath(cst, '0.markup.renderArguments').to.have.lengthOf(namedArguments.length);
+ namedArguments.forEach(({ name, valueType }, i) => {
+ expectPath(cst, `0.markup.renderArguments.${i}.type`).to.equal('NamedArgument');
+ expectPath(cst, `0.markup.renderArguments.${i}.name`).to.equal(name);
+ expectPath(cst, `0.markup.renderArguments.${i}.value.type`).to.equal(valueType);
+ });
+ expectPath(cst, '0.whitespaceStart').to.equal(null);
+ expectPath(cst, '0.whitespaceEnd').to.equal('-');
+ }
+ },
+ );
});
it('should parse the response_status tag', () => {
diff --git a/packages/liquid-html-parser/src/stage-2-ast.spec.ts b/packages/liquid-html-parser/src/stage-2-ast.spec.ts
index 2844b0f..fd182f2 100644
--- a/packages/liquid-html-parser/src/stage-2-ast.spec.ts
+++ b/packages/liquid-html-parser/src/stage-2-ast.spec.ts
@@ -1543,15 +1543,102 @@ describe('Unit: Stage 2 (AST)', () => {
});
it('should parse the theme_render_rc tag', () => {
- for (const { toAST, expectPath, expectPosition } of testCases) {
- ast = toAST(`{% theme_render_rc 'partial' %}`);
- expectPath(ast, 'children.0.type').to.equal('LiquidTag');
- expectPath(ast, 'children.0.name').to.equal('theme_render_rc');
- expectPath(ast, 'children.0.markup.type').to.equal('RenderMarkup');
- expectPath(ast, 'children.0.markup.partial.type').to.equal('String');
- expectPosition(ast, 'children.0');
- expectPosition(ast, 'children.0.markup');
- }
+ [
+ {
+ expression: `'partial'`,
+ partialType: 'String',
+ alias: null,
+ renderVariableExpression: null,
+ namedArguments: [],
+ },
+ {
+ expression: `'partial' as foo`,
+ partialType: 'String',
+ alias: {
+ value: 'foo',
+ },
+ renderVariableExpression: null,
+ namedArguments: [],
+ },
+ {
+ expression: `'partial' with product as item`,
+ partialType: 'String',
+ alias: {
+ value: 'item',
+ },
+ renderVariableExpression: {
+ kind: 'with',
+ name: {
+ type: 'VariableLookup',
+ },
+ },
+ namedArguments: [],
+ },
+ {
+ expression: `'partial' for products as product`,
+ partialType: 'String',
+ alias: {
+ value: 'product',
+ },
+ renderVariableExpression: {
+ kind: 'for',
+ name: {
+ type: 'VariableLookup',
+ },
+ },
+ namedArguments: [],
+ },
+ {
+ expression: `'my/partial', key1: val1, key2: "hi"`,
+ partialType: 'String',
+ alias: null,
+ renderVariableExpression: null,
+ namedArguments: [
+ { name: 'key1', valueType: 'VariableLookup' },
+ { name: 'key2', valueType: 'String' },
+ ],
+ },
+ ].forEach(
+ ({ expression, partialType, renderVariableExpression, alias, namedArguments }) => {
+ for (const { toAST, expectPath, expectPosition } of testCases) {
+ ast = toAST(`{% theme_render_rc ${expression} -%}`);
+ expectPath(ast, 'children.0.type').to.equal('LiquidTag');
+ expectPath(ast, 'children.0.name').to.equal('theme_render_rc');
+ expectPath(ast, 'children.0.markup.type').to.equal('RenderMarkup');
+ expectPath(ast, 'children.0.markup.partial.type').to.equal(partialType);
+ if (renderVariableExpression) {
+ expectPath(ast, 'children.0.markup.variable.type').to.equal(
+ 'RenderVariableExpression',
+ );
+ expectPath(ast, 'children.0.markup.variable.kind').to.equal(
+ renderVariableExpression.kind,
+ );
+ expectPath(ast, 'children.0.markup.variable.name.type').to.equal(
+ renderVariableExpression.name.type,
+ );
+ } else {
+ expectPath(ast, 'children.0.markup.variable').to.equal(null);
+ }
+ if (alias) {
+ expectPath(ast, 'children.0.markup.alias.value').to.equal(alias.value);
+ } else {
+ expectPath(ast, 'children.0.markup.alias').to.equal(null);
+ }
+ expectPath(ast, 'children.0.markup.args').to.have.lengthOf(namedArguments.length);
+ namedArguments.forEach(({ name, valueType }, i) => {
+ expectPath(ast, `children.0.markup.args.${i}.type`).to.equal('NamedArgument');
+ expectPath(ast, `children.0.markup.args.${i}.name`).to.equal(name);
+ expectPath(ast, `children.0.markup.args.${i}.value.type`).to.equal(valueType);
+ expectPosition(ast, `children.0.markup.args.${i}`);
+ expectPosition(ast, `children.0.markup.args.${i}.value`);
+ });
+ expectPath(ast, 'children.0.whitespaceStart').to.equal('');
+ expectPath(ast, 'children.0.whitespaceEnd').to.equal('-');
+ expectPosition(ast, 'children.0');
+ expectPosition(ast, 'children.0.markup');
+ }
+ },
+ );
});
it('should parse the response_status tag', () => {
diff --git a/packages/platformos-check-common/src/checks/missing-page/index.ts b/packages/platformos-check-common/src/checks/missing-page/index.ts
index 3c60c54..d54538d 100644
--- a/packages/platformos-check-common/src/checks/missing-page/index.ts
+++ b/packages/platformos-check-common/src/checks/missing-page/index.ts
@@ -1,10 +1,4 @@
-import {
- HtmlElement,
- LiquidTag,
- NamedTags,
- LiquidTagAssign,
- AssignMarkup,
-} from '@platformos/liquid-html-parser';
+import { HtmlElement, LiquidTag } from '@platformos/liquid-html-parser';
import { RouteTable } from '@platformos/platformos-common';
import {
shouldSkipUrl,
@@ -12,7 +6,7 @@ import {
getAttrName,
extractUrlPattern,
getEffectiveMethod,
- resolveAssignToUrlPattern,
+ tryExtractAssignUrl,
} from '../../url-helpers';
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
import { isHtmlTag } from '../utils';
@@ -56,22 +50,19 @@ export const MissingPage: LiquidCheckDefinition = {
}
return {
- async LiquidTag(node: LiquidTag) {
- if (node.name !== NamedTags.assign) return;
- const markup = (node as LiquidTagAssign).markup as AssignMarkup;
- if (markup.lookups.length > 0) return;
+ async onCodePathStart() {
+ // Front-load the route table build so individual HtmlElement visits don't wait.
+ routeTable = await context.getRouteTable();
+ },
- const urlPattern = resolveAssignToUrlPattern(markup);
- if (urlPattern !== null) {
- variableMap.set(markup.name, urlPattern);
+ async LiquidTag(node: LiquidTag) {
+ const extracted = tryExtractAssignUrl(node);
+ if (extracted) {
+ variableMap.set(extracted.name, extracted.urlPattern);
}
},
async HtmlElement(node) {
- if (!routeTable) {
- routeTable = await context.getRouteTable();
- }
-
if (isHtmlTag(node, 'a')) {
const hrefAttr = node.attributes.find(
(a) => isValuedAttrNode(a) && getAttrName(a) === 'href',
diff --git a/packages/platformos-check-common/src/checks/missing-partial/index.spec.ts b/packages/platformos-check-common/src/checks/missing-partial/index.spec.ts
index 0fc0b4a..c70ef7f 100644
--- a/packages/platformos-check-common/src/checks/missing-partial/index.spec.ts
+++ b/packages/platformos-check-common/src/checks/missing-partial/index.spec.ts
@@ -47,6 +47,19 @@ describe('Module: MissingPartial', () => {
'app/views/partials/partial.liquid': file,
}),
},
+ {
+ testCase: 'should report the missing partial to be rendered with "theme_render_rc"',
+ file: "{% theme_render_rc 'missing' %}",
+ expected: {
+ message: "'missing' does not exist",
+ uri: 'file:///app/views/partials/partial.liquid',
+ start: { index: 19, line: 0, character: 19 },
+ end: { index: 28, line: 0, character: 28 },
+ },
+ filesWith: (file: string) => ({
+ 'app/views/partials/partial.liquid': file,
+ }),
+ },
];
for (const { testCase, file, expected, filesWith } of testCases) {
const offenses = await check(filesWith(file), [MissingPartial]);
@@ -58,4 +71,352 @@ describe('Module: MissingPartial', () => {
});
}
});
+
+ it('should not report when the partial exists for theme_render_rc', async () => {
+ const offenses = await check(
+ {
+ 'app/views/partials/partial.liquid':
+ "{% theme_render_rc 'my_product', class: 'featured' %}",
+ 'app/views/partials/my_product.liquid': '
Product
',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should not report theme_render_rc with variable lookup', async () => {
+ const offenses = await check(
+ {
+ 'app/views/partials/partial.liquid':
+ "{% theme_render_rc 'existing' for products as product %}",
+ 'app/views/partials/existing.liquid': '{{ product }}',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ describe('theme_render_rc with theme_search_paths', () => {
+ it('should find partial via first search path (highest priority)', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'another/super/partial' %}",
+ 'app/views/partials/theme/dress/another/super/partial.liquid': 'dress partial',
+ 'app/views/partials/theme/simple/another/super/partial.liquid': 'simple partial',
+ 'app/views/partials/another/super/partial.liquid': 'default partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should find partial via second search path when first does not have it', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'my/partial' %}",
+ 'app/views/partials/theme/simple/my/partial.liquid': 'simple partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should fallback to default path when no search path matches', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'default' %}",
+ 'app/views/partials/default.liquid': 'default partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should report missing when partial is not found in any search path or fallback', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'my/missing' %}",
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(1);
+ expect(offenses).to.containOffense({
+ check: 'MissingPartial',
+ message: "'my/missing' does not exist",
+ });
+ });
+
+ it('should respect empty string in search paths as default path position', async () => {
+ // With ['theme/dress', '', 'theme/simple'], the empty string means "default path"
+ // appears between dress and simple in priority order.
+ // So if dress doesn't have it but default path does, use default (skip simple).
+ const offenses = await check(
+ {
+ 'app/config.yml': "theme_search_paths:\n - theme/dress\n - ''\n - theme/simple",
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'my/partial' %}",
+ 'app/views/partials/my/partial.liquid': 'default partial',
+ 'app/views/partials/theme/simple/my/partial.liquid': 'simple partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should expand Liquid expressions as wildcards and find matching partial', async () => {
+ // {{ context.constants.MY_THEME }} acts as a wildcard matching any subdirectory.
+ // With theme/custom_theme/my/partial.liquid existing, the wildcard should match it.
+ const offenses = await check(
+ {
+ 'app/config.yml':
+ 'theme_search_paths:\n - theme/{{ context.constants.MY_THEME }}\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'my/partial' %}",
+ 'app/views/partials/theme/custom_theme/my/partial.liquid': 'custom theme partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should find partial via static path when dynamic path has no match', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml':
+ 'theme_search_paths:\n - theme/{{ context.constants.MY_THEME }}\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'product' %}",
+ 'app/views/partials/theme/simple/product.liquid': 'simple partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should report missing when wildcard expansion finds no matching partial', async () => {
+ // Dynamic path expands but the partial doesn't exist in any expanded directory
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/{{ context.constants.MY_THEME }}',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'missing' %}",
+ 'app/views/partials/theme/custom/other.liquid': 'wrong partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(1);
+ expect(offenses).to.containOffense({
+ check: 'MissingPartial',
+ message: "'missing' does not exist",
+ });
+ });
+
+ it('should handle multiple Liquid expressions in a path', async () => {
+ // E.g. "{{ context.constants.BRAND }}/{{ context.constants.TIER }}" - both segments are wildcards
+ const offenses = await check(
+ {
+ 'app/config.yml':
+ 'theme_search_paths:\n - "{{ context.constants.BRAND }}/{{ context.constants.TIER }}"',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'card' %}",
+ 'app/views/partials/acme/premium/card.liquid': 'acme premium card',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should render partial which includes path in its name', async () => {
+ // When the partial name itself includes the search path prefix
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'theme/simple/my/partial' %}",
+ 'app/views/partials/theme/simple/my/partial.liquid': 'simple partial',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should work with nested partial directories', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'components/card' %}",
+ 'app/views/partials/theme/dress/components/card.liquid': 'dress card',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should not affect regular render tags even when config exists', async () => {
+ // 'card' exists under the search path prefix, so theme_render_rc would find it,
+ // but render ignores search paths entirely and looks only in the default locations.
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'app/views/partials/page.liquid': "{% render 'card' %}",
+ 'app/views/partials/theme/simple/card.liquid': 'simple card',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(1);
+ expect(offenses).to.containOffense({
+ check: 'MissingPartial',
+ message: "'card' does not exist",
+ });
+ });
+
+ it('should also search in app/lib with search paths', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'my_helper' %}",
+ 'app/lib/theme/dress/my_helper.liquid': 'helper from lib',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should fallback to standard render resolution when no config.yml exists', async () => {
+ const offenses = await check(
+ {
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'existing' %}",
+ 'app/views/partials/existing.liquid': 'found it',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should handle theme_render_rc with named arguments and search paths', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'app/views/partials/page.liquid':
+ "{% theme_render_rc 'product', class: 'featured', size: 'large' %}",
+ 'app/views/partials/theme/dress/product.liquid': '{{ class }} {{ size }}',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should handle theme_render_rc with for/with and search paths', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'item' for products as product %}",
+ 'app/views/partials/theme/dress/item.liquid': '{{ product }}',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should treat empty theme_search_paths array same as absent', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths: []',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'existing' %}",
+ 'app/views/partials/existing.liquid': 'found it',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should handle malformed theme_search_paths (not an array)', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths: some_string',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'existing' %}",
+ 'app/views/partials/existing.liquid': 'found it',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should handle multiple theme_render_rc tags in one file', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'app/views/partials/page.liquid':
+ "{% theme_render_rc 'header' %} {% theme_render_rc 'footer' %} {% theme_render_rc 'missing' %}",
+ 'app/views/partials/theme/dress/header.liquid': 'header',
+ 'app/views/partials/theme/simple/footer.liquid': 'footer',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(1);
+ expect(offenses).to.containOffense({
+ check: 'MissingPartial',
+ message: "'missing' does not exist",
+ });
+ });
+
+ it('should handle theme_render_rc inside {% liquid %} block', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'app/views/partials/page.liquid': "{% liquid\n theme_render_rc 'card'\n%}",
+ 'app/views/partials/theme/dress/card.liquid': 'card',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should resolve module-prefixed partials via fallback', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'modules/shop/card' %}",
+ 'modules/shop/public/views/partials/card.liquid': 'module card',
+ },
+ [MissingPartial],
+ );
+
+ expect(offenses).to.have.length(0);
+ });
+
+ it('should handle non-string entries in theme_search_paths gracefully', async () => {
+ const offenses = await check(
+ {
+ 'app/config.yml': 'theme_search_paths:\n - 123\n - true',
+ 'app/views/partials/page.liquid': "{% theme_render_rc 'card' %}",
+ 'app/views/partials/card.liquid': 'default card',
+ },
+ [MissingPartial],
+ );
+
+ // 123/card and true/card won't exist, but fallback to unprefixed finds it
+ expect(offenses).to.have.length(0);
+ });
+ });
});
diff --git a/packages/platformos-check-common/src/checks/missing-partial/index.ts b/packages/platformos-check-common/src/checks/missing-partial/index.ts
index fb7f7d0..9de45bf 100644
--- a/packages/platformos-check-common/src/checks/missing-partial/index.ts
+++ b/packages/platformos-check-common/src/checks/missing-partial/index.ts
@@ -1,8 +1,16 @@
-import { NodeTypes } from '@platformos/liquid-html-parser';
+import { LiquidHtmlNode, NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
import { LiquidCheckDefinition, SchemaProp, Severity, SourceCodeType } from '../../types';
-import { DocumentsLocator } from '@platformos/platformos-common';
+import { DocumentsLocator, DocumentType, loadSearchPaths } from '@platformos/platformos-common';
import { URI } from 'vscode-uri';
+function getTagName(ancestors: LiquidHtmlNode[]): DocumentType {
+ const parent = ancestors.at(-1);
+ if (parent?.type === NodeTypes.LiquidTag && parent.name === NamedTags.theme_render_rc) {
+ return 'theme_render_rc';
+ }
+ return 'render';
+}
+
const schema = {
ignoreMissing: SchemaProp.array(SchemaProp.string(), []),
};
@@ -24,62 +32,46 @@ export const MissingPartial: LiquidCheckDefinition = {
create(context) {
const locator = new DocumentsLocator(context.fs);
+ const rootUri = URI.parse(context.config.rootUri);
+ let searchPathsPromise: Promise | undefined;
- return {
- async RenderMarkup(node) {
- if (node.partial.type === NodeTypes.VariableLookup) return;
+ function getSearchPaths(): Promise {
+ searchPathsPromise ??= loadSearchPaths(context.fs, rootUri);
+ return searchPathsPromise;
+ }
- const partial = node.partial;
- const location = await locator.locate(
- URI.parse(context.config.rootUri),
- 'render',
- partial.value,
- );
+ async function reportIfMissing(
+ docType: DocumentType,
+ name: string,
+ position: LiquidHtmlNode['position'],
+ ) {
+ const searchPaths = docType === 'theme_render_rc' ? await getSearchPaths() : null;
+ const location = await locator.locate(rootUri, docType, name, searchPaths);
+ if (!location) {
+ context.report({
+ message: `'${name}' does not exist`,
+ startIndex: position.start,
+ endIndex: position.end,
+ });
+ }
+ }
- if (!location) {
- context.report({
- message: `'${partial.value}' does not exist`,
- startIndex: node.partial.position.start,
- endIndex: node.partial.position.end,
- });
+ return {
+ async RenderMarkup(node, ancestors) {
+ if (node.partial.type !== NodeTypes.VariableLookup) {
+ await reportIfMissing(getTagName(ancestors), node.partial.value, node.partial.position);
}
},
async FunctionMarkup(node) {
- if (node.partial.type === NodeTypes.VariableLookup) return;
-
- const partial = node.partial;
- const location = await locator.locate(
- URI.parse(context.config.rootUri),
- 'function',
- partial.value,
- );
-
- if (!location) {
- context.report({
- message: `'${partial.value}' does not exist`,
- startIndex: node.partial.position.start,
- endIndex: node.partial.position.end,
- });
+ if (node.partial.type !== NodeTypes.VariableLookup) {
+ await reportIfMissing('function', node.partial.value, node.partial.position);
}
},
async GraphQLMarkup(node) {
- if (node.graphql.type === NodeTypes.VariableLookup) return;
-
- const graphql = node.graphql;
- const location = await locator.locate(
- URI.parse(context.config.rootUri),
- 'graphql',
- graphql.value,
- );
-
- if (!location) {
- context.report({
- message: `'${graphql.value}' does not exist`,
- startIndex: node.graphql.position.start,
- endIndex: node.graphql.position.end,
- });
+ if (node.graphql.type !== NodeTypes.VariableLookup) {
+ await reportIfMissing('graphql', node.graphql.value, node.graphql.position);
}
},
};
diff --git a/packages/platformos-check-common/src/context-utils.ts b/packages/platformos-check-common/src/context-utils.ts
index ea9b314..d3b2413 100644
--- a/packages/platformos-check-common/src/context-utils.ts
+++ b/packages/platformos-check-common/src/context-utils.ts
@@ -144,7 +144,13 @@ export function makeGetRouteTable(
if (table.isBuilt()) {
buildPromise = Promise.resolve(table);
} else {
- buildPromise = table.build(URI.parse(rootUri)).then(() => table);
+ buildPromise = table
+ .build(URI.parse(rootUri))
+ .then(() => table)
+ .catch((err) => {
+ buildPromise = null;
+ throw err;
+ });
}
}
return buildPromise;
diff --git a/packages/platformos-check-common/src/url-helpers.ts b/packages/platformos-check-common/src/url-helpers.ts
index 61e86ff..d191bd3 100644
--- a/packages/platformos-check-common/src/url-helpers.ts
+++ b/packages/platformos-check-common/src/url-helpers.ts
@@ -28,6 +28,24 @@ import {
* remains in platformos-common.
*/
+/** Extract the `children` array from a node if it has one (block tags, elements). */
+function getTraversableChildren(node: LiquidHtmlNode): LiquidHtmlNode[] | null {
+ if ('children' in node) {
+ const children = (node as { children: unknown }).children;
+ if (Array.isArray(children)) return children as LiquidHtmlNode[];
+ }
+ return null;
+}
+
+/** Extract the `markup` array from a node if it is an array ({% liquid %} tags). */
+function getTraversableMarkup(node: LiquidHtmlNode): LiquidHtmlNode[] | null {
+ if ('markup' in node) {
+ const markup = (node as { markup: unknown }).markup;
+ if (Array.isArray(markup)) return markup as LiquidHtmlNode[];
+ }
+ return null;
+}
+
const SKIP_PREFIXES = ['http://', 'https://', '//', 'mailto:', 'tel:', 'javascript:', 'data:', '#'];
export function shouldSkipUrl(url: string): boolean {
@@ -279,6 +297,26 @@ export function getEffectiveMethod(formNode: HtmlElement): string | null {
return formMethod;
}
+/**
+ * If the given node is a `{% assign %}` tag whose RHS resolves to a URL pattern,
+ * returns `{ name, urlPattern }`. Otherwise returns null.
+ *
+ * Shared by `buildVariableMap` (full AST walk) and `MissingPage` (incremental
+ * visitor), so the assign-to-URL resolution logic lives in one place.
+ */
+export function tryExtractAssignUrl(
+ node: LiquidHtmlNode,
+): { name: string; urlPattern: string } | null {
+ if (node.type !== NodeTypes.LiquidTag || (node as LiquidTag).name !== NamedTags.assign) {
+ return null;
+ }
+ const markup = (node as LiquidTagAssign).markup as AssignMarkup;
+ if (markup.lookups.length > 0) return null;
+ const urlPattern = resolveAssignToUrlPattern(markup);
+ if (urlPattern === null) return null;
+ return { name: markup.name, urlPattern };
+}
+
/**
* Walk an AST subtree and collect {% assign %} variable mappings that resolve to URL patterns.
* Not scope-aware: assigns inside {% if %} / {% for %} blocks are tracked even though they
@@ -299,26 +337,24 @@ export function buildVariableMap(
function walk(nodes: LiquidHtmlNode[]): void {
for (const node of nodes) {
- if (node.type === NodeTypes.LiquidTag && (node as LiquidTag).name === NamedTags.assign) {
- if (beforeOffset !== undefined && node.position.end > beforeOffset) continue;
- const markup = (node as LiquidTagAssign).markup as AssignMarkup;
- if (markup.lookups.length === 0) {
- const urlPattern = resolveAssignToUrlPattern(markup);
- if (urlPattern !== null) {
- variableMap.set(markup.name, urlPattern);
- }
+ // Apply the beforeOffset cutoff only to assign nodes themselves, not to block
+ // containers. A block tag ({% if %}, {% for %}, HTML element, etc.) may start
+ // before the cursor but end after it — we must still recurse into its children
+ // to find any assigns that precede the cursor within that block.
+ const extracted = tryExtractAssignUrl(node);
+ if (extracted) {
+ if (beforeOffset === undefined || node.position.end <= beforeOffset) {
+ variableMap.set(extracted.name, extracted.urlPattern);
}
}
// Recurse into children (block tags like {% if %}, {% for %})
- if ('children' in node && Array.isArray((node as any).children)) {
- walk((node as any).children);
- }
+ const children = getTraversableChildren(node);
+ if (children) walk(children);
// Recurse into markup arrays ({% liquid %} block contains assigns in markup)
- if ('markup' in node && Array.isArray((node as any).markup)) {
- walk((node as any).markup);
- }
+ const markupArray = getTraversableMarkup(node);
+ if (markupArray) walk(markupArray);
}
}
diff --git a/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts b/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts
index 62081a5..af59b76 100644
--- a/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts
+++ b/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts
@@ -1,16 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { URI } from 'vscode-uri';
-import { DocumentsLocator } from './DocumentsLocator';
+import { DocumentsLocator, loadSearchPaths } from './DocumentsLocator';
import { AbstractFileSystem, FileType, FileStat, FileTuple } from '../AbstractFileSystem';
function createMockFileSystem(files: Record): AbstractFileSystem {
const fileSet = new Set(Object.keys(files));
+ // Build directory tree from file paths
+ const dirs = new Set();
+ for (const filePath of fileSet) {
+ const parts = filePath.split('/');
+ for (let i = 1; i < parts.length; i++) {
+ dirs.add(parts.slice(0, i).join('/'));
+ }
+ }
+
return {
stat: vi.fn(async (uri: string): Promise => {
if (fileSet.has(uri)) {
return { type: FileType.File, size: files[uri].length };
}
+ if (dirs.has(uri)) {
+ return { type: FileType.Directory, size: 0 };
+ }
throw new Error(`File not found: ${uri}`);
}),
readFile: vi.fn(async (uri: string): Promise => {
@@ -21,10 +33,19 @@ function createMockFileSystem(files: Record): AbstractFileSystem
}),
readDirectory: vi.fn(async (uri: string): Promise => {
const results: FileTuple[] = [];
- for (const filePath of fileSet) {
- if (filePath.startsWith(uri)) {
- results.push([filePath, FileType.File]);
- }
+ const seen = new Set();
+ const prefix = uri.endsWith('/') ? uri : uri + '/';
+
+ for (const path of [...fileSet, ...dirs]) {
+ if (!path.startsWith(prefix)) continue;
+ const rest = path.slice(prefix.length);
+ const firstSegment = rest.split('/')[0];
+ if (!firstSegment || seen.has(firstSegment)) continue;
+ seen.add(firstSegment);
+
+ const fullPath = prefix + firstSegment;
+ const isDir = dirs.has(fullPath) && !fileSet.has(fullPath);
+ results.push([fullPath, isDir ? FileType.Directory : FileType.File]);
}
return results;
}),
@@ -137,4 +158,277 @@ describe('DocumentsLocator', () => {
expect(result).toEqual([]);
});
});
+
+ describe('locateWithSearchPaths', () => {
+ it('should find partial via first search path', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/theme/dress/card.liquid': 'dress',
+ 'file:///project/app/views/partials/theme/simple/card.liquid': 'simple',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
+ 'theme/dress',
+ 'theme/simple',
+ ]);
+
+ expect(result).toBe('file:///project/app/views/partials/theme/dress/card.liquid');
+ });
+
+ it('should fall through to second search path', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/theme/simple/card.liquid': 'simple',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
+ 'theme/dress',
+ 'theme/simple',
+ ]);
+
+ expect(result).toBe('file:///project/app/views/partials/theme/simple/card.liquid');
+ });
+
+ it('should fallback to unprefixed path when no search path matches', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/card.liquid': 'default',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
+ 'theme/dress',
+ 'theme/simple',
+ ]);
+
+ expect(result).toBe('file:///project/app/views/partials/card.liquid');
+ });
+
+ it('should not fallback when empty string is in search paths', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/card.liquid': 'default',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ // '' is at position 0, so it tries default path first, finds it
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', ['', 'theme/dress']);
+ expect(result).toBe('file:///project/app/views/partials/card.liquid');
+ });
+
+ it('should return undefined when nothing matches', async () => {
+ const fs = createMockFileSystem({});
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/dress']);
+ expect(result).toBeUndefined();
+ });
+
+ it('should handle nested partial names with search paths', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/theme/dress/components/hero.liquid': 'hero',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'components/hero', [
+ 'theme/dress',
+ ]);
+
+ expect(result).toBe('file:///project/app/views/partials/theme/dress/components/hero.liquid');
+ });
+
+ it('should also search app/lib with search paths', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/lib/theme/dress/helper.liquid': 'helper',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'helper', ['theme/dress']);
+
+ expect(result).toBe('file:///project/app/lib/theme/dress/helper.liquid');
+ });
+
+ it('should expand dynamic Liquid expressions as wildcards', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/theme/custom/card.liquid': 'custom card',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
+ 'theme/{{ context.constants.THEME }}',
+ ]);
+
+ expect(result).toBe('file:///project/app/views/partials/theme/custom/card.liquid');
+ });
+
+ it('should expand multiple wildcards in a single path', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/acme/premium/card.liquid': 'card',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'card', [
+ '{{ context.constants.BRAND }}/{{ context.constants.TIER }}',
+ ]);
+
+ expect(result).toBe('file:///project/app/views/partials/acme/premium/card.liquid');
+ });
+
+ it('should return undefined when wildcard expands but partial not found', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/theme/custom/other.liquid': 'other',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result = await locator.locateWithSearchPaths(rootUri, 'missing', [
+ 'theme/{{ context.constants.THEME }}',
+ ]);
+
+ // Fallback to unprefixed — also not found
+ expect(result).toBeUndefined();
+ });
+
+ it('should cache expanded paths across calls', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/theme/custom/a.liquid': 'a',
+ 'file:///project/app/views/partials/theme/custom/b.liquid': 'b',
+ });
+ const locator = new DocumentsLocator(fs);
+ const searchPaths = ['theme/{{ x }}'];
+
+ await locator.locateWithSearchPaths(rootUri, 'a', searchPaths);
+ const readDirSpy = fs.readDirectory as ReturnType;
+ const callCountAfterFirst = readDirSpy.mock.calls.length;
+
+ await locator.locateWithSearchPaths(rootUri, 'b', searchPaths);
+ // readDirectory should not be called again for wildcard expansion
+ // (only for locateFile stat calls, not for listSubdirectories)
+ const expansionCalls = readDirSpy.mock.calls.filter(
+ (call: string[]) =>
+ call[0].includes('app/views/partials/theme') && !call[0].includes('.liquid'),
+ );
+ // All expansion readDirectory calls should come from the first invocation
+ const expansionCallsAfterFirst = readDirSpy.mock.calls
+ .slice(callCountAfterFirst)
+ .filter(
+ (call: string[]) =>
+ call[0].includes('app/views/partials/theme') && !call[0].includes('.liquid'),
+ );
+ expect(expansionCallsAfterFirst).toHaveLength(0);
+ });
+
+ it('should clear expanded paths cache', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/views/partials/theme/v1/card.liquid': 'v1',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ const result1 = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/{{ version }}']);
+ expect(result1).toBe('file:///project/app/views/partials/theme/v1/card.liquid');
+
+ locator.clearExpandedPathsCache();
+
+ // After clearing, a fresh expansion should work (same result since fs unchanged)
+ const result2 = await locator.locateWithSearchPaths(rootUri, 'card', ['theme/{{ version }}']);
+ expect(result2).toBe('file:///project/app/views/partials/theme/v1/card.liquid');
+ });
+
+ it('should handle module-prefixed partials with search paths', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/modules/shop/public/views/partials/card.liquid': 'module card',
+ });
+ const locator = new DocumentsLocator(fs);
+
+ // module path in fallback (search paths don't apply to module prefix)
+ const result = await locator.locateWithSearchPaths(rootUri, 'modules/shop/card', [
+ 'theme/dress',
+ ]);
+
+ expect(result).toBe('file:///project/app/modules/shop/public/views/partials/card.liquid');
+ });
+ });
+
+ describe('loadSearchPaths', () => {
+ it('should load valid theme_search_paths from config', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toEqual(['theme/dress', 'theme/simple']);
+ });
+
+ it('should return null when config file does not exist', async () => {
+ const fs = createMockFileSystem({});
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toBeNull();
+ });
+
+ it('should return null for empty array', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml': 'theme_search_paths: []',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toBeNull();
+ });
+
+ it('should return null when theme_search_paths is not an array', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml': 'theme_search_paths: some_string',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toBeNull();
+ });
+
+ it('should return null when config has no theme_search_paths key', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml': 'some_other_key: value',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toBeNull();
+ });
+
+ it('should coerce non-string entries to strings', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml': 'theme_search_paths:\n - 123\n - true\n - null',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toEqual(['123', 'true', 'null']);
+ });
+
+ it('should handle config with Liquid expressions in paths', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml':
+ 'theme_search_paths:\n - "theme/{{ context.constants.MY_THEME | default: \'custom\' }}"\n - theme/simple',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toEqual([
+ "theme/{{ context.constants.MY_THEME | default: 'custom' }}",
+ 'theme/simple',
+ ]);
+ });
+
+ it('should handle malformed YAML gracefully', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml': '{{invalid yaml',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toBeNull();
+ });
+
+ it('should handle config with other properties alongside theme_search_paths', async () => {
+ const fs = createMockFileSystem({
+ 'file:///project/app/config.yml':
+ 'some_setting: true\ntheme_search_paths:\n - theme/dress\nanother_setting: 42',
+ });
+
+ const result = await loadSearchPaths(fs, rootUri);
+ expect(result).toEqual(['theme/dress']);
+ });
+ });
});
diff --git a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts
index e9ae461..f130d5c 100644
--- a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts
+++ b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts
@@ -1,8 +1,38 @@
+import yaml from 'js-yaml';
import { AbstractFileSystem, FileType } from '../AbstractFileSystem';
import { getAppPaths, getModulePaths, PlatformOSFileType } from '../path-utils';
import { URI, Utils } from 'vscode-uri';
-export type DocumentType = 'function' | 'render' | 'include' | 'graphql' | 'asset';
+export type DocumentType =
+ | 'function'
+ | 'render'
+ | 'include'
+ | 'graphql'
+ | 'asset'
+ | 'theme_render_rc';
+
+/**
+ * Load theme_search_paths from app/config.yml.
+ * Returns null if the file doesn't exist, is malformed, or has no valid theme_search_paths.
+ * Results should be cached per root URI.
+ */
+export async function loadSearchPaths(
+ fs: { readFile(uri: string): Promise },
+ rootUri: URI,
+): Promise {
+ try {
+ const configUri = Utils.joinPath(rootUri, 'app/config.yml').toString();
+ const content = await fs.readFile(configUri);
+ const config = yaml.load(content) as Record | null;
+ const paths = config?.theme_search_paths;
+ if (Array.isArray(paths) && paths.length > 0) {
+ return paths.map(String);
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
type ModulePathInfo =
| { isModule: false; key: string }
@@ -127,10 +157,117 @@ export class DocumentsLocator {
return Array.from(results).sort((a, b) => a.localeCompare(b));
}
+ private static readonly LIQUID_EXPRESSION_RE = /\{\{.*?\}\}/;
+
+ private async listSubdirectoryNames(dirUri: string): Promise {
+ try {
+ const entries = await this.fs.readDirectory(dirUri);
+ return entries
+ .filter(([, type]) => type === FileType.Directory)
+ .map(([name]) => {
+ const lastSlash = name.lastIndexOf('/');
+ return lastSlash === -1 ? name : name.slice(lastSlash + 1);
+ })
+ .filter((name) => name.length > 0);
+ } catch {
+ return [];
+ }
+ }
+
+ private expandedPathsCache = new Map>();
+
+ clearExpandedPathsCache(): void {
+ this.expandedPathsCache.clear();
+ }
+
+ /**
+ * Expand a search path that may contain {{ ... }} Liquid expressions into
+ * concrete directory prefixes by enumerating subdirectories at each dynamic
+ * segment. Static segments pass through unchanged.
+ *
+ * Results are cached per (rootUri, searchPath) and capped at 100 entries.
+ */
+ private async expandDynamicPath(rootUri: URI, searchPath: string): Promise {
+ const segments = searchPath.split('/');
+ const basePaths = this.getSearchPaths('partial');
+ let prefixes = [''];
+
+ for (const segment of segments) {
+ if (!DocumentsLocator.LIQUID_EXPRESSION_RE.test(segment)) {
+ prefixes = prefixes.map((p) => (p ? `${p}/${segment}` : segment));
+ continue;
+ }
+
+ const nextPrefixes: string[] = [];
+ for (const prefix of prefixes) {
+ const subdirs = new Set();
+ for (const base of basePaths) {
+ const dirUri = prefix
+ ? Utils.joinPath(rootUri, base, prefix).toString()
+ : Utils.joinPath(rootUri, base).toString();
+ for (const name of await this.listSubdirectoryNames(dirUri)) {
+ subdirs.add(name);
+ }
+ }
+ for (const sub of subdirs) {
+ nextPrefixes.push(prefix ? `${prefix}/${sub}` : sub);
+ if (nextPrefixes.length >= 100) break;
+ }
+ if (nextPrefixes.length >= 100) break;
+ }
+ prefixes = nextPrefixes;
+ }
+
+ return prefixes;
+ }
+
+ /**
+ * Resolve a search path (static, dynamic, or empty) into concrete prefix
+ * strings. Cached for dynamic paths.
+ */
+ private async resolveSearchPath(rootUri: URI, searchPath: string): Promise {
+ if (searchPath === '') return [''];
+ if (!DocumentsLocator.LIQUID_EXPRESSION_RE.test(searchPath)) return [searchPath];
+
+ const cacheKey = `${rootUri.toString()}:${searchPath}`;
+ if (!this.expandedPathsCache.has(cacheKey)) {
+ this.expandedPathsCache.set(cacheKey, this.expandDynamicPath(rootUri, searchPath));
+ }
+ return this.expandedPathsCache.get(cacheKey)!;
+ }
+
+ /**
+ * Locate a partial using theme search paths (for theme_render_rc).
+ *
+ * Tries each search path prefix in priority order, then falls back to the
+ * unprefixed name (unless '' was already in the list, meaning the default
+ * position was explicitly placed).
+ */
+ async locateWithSearchPaths(
+ rootUri: URI,
+ fileName: string,
+ themeSearchPaths: string[],
+ ): Promise {
+ for (const searchPath of themeSearchPaths) {
+ for (const prefix of await this.resolveSearchPath(rootUri, searchPath)) {
+ const candidate = prefix ? `${prefix}/${fileName}` : fileName;
+ const result = await this.locateFile(rootUri, candidate, 'partial');
+ if (result) return result;
+ }
+ }
+
+ if (!themeSearchPaths.includes('')) {
+ return this.locateFile(rootUri, fileName, 'partial');
+ }
+
+ return undefined;
+ }
+
async locate(
rootUri: URI,
nodeName: DocumentType,
fileName: string,
+ themeSearchPaths?: string[] | null,
): Promise {
switch (nodeName) {
case 'render':
@@ -138,6 +275,11 @@ export class DocumentsLocator {
case 'function':
return this.locateFile(rootUri, fileName, 'partial');
+ case 'theme_render_rc':
+ return themeSearchPaths
+ ? this.locateWithSearchPaths(rootUri, fileName, themeSearchPaths)
+ : this.locateFile(rootUri, fileName, 'partial');
+
case 'graphql':
return this.locateFile(rootUri, fileName, 'graphql');
@@ -154,6 +296,7 @@ export class DocumentsLocator {
case 'function':
case 'render':
case 'include':
+ case 'theme_render_rc':
return this.listFiles(rootUri, filePrefix, 'partial');
case 'graphql':
diff --git a/packages/platformos-common/src/route-table/RouteTable.spec.ts b/packages/platformos-common/src/route-table/RouteTable.spec.ts
index 6cb71b8..8dda50e 100644
--- a/packages/platformos-common/src/route-table/RouteTable.spec.ts
+++ b/packages/platformos-common/src/route-table/RouteTable.spec.ts
@@ -577,4 +577,132 @@ describe('RouteTable', () => {
expect(rt.hasMatch('/api/endpoint.json')).toBe(true);
});
});
+
+ describe('routeCount', () => {
+ it('returns 0 for empty table', () => {
+ const rt = new RouteTable(createMockFileSystem({}));
+ expect(rt.routeCount()).toBe(0);
+ });
+
+ it('counts single route entry', async () => {
+ const fs = createMockFileSystem(
+ Object.fromEntries([page('app/views/pages/about.html.liquid')]),
+ );
+ const rt = new RouteTable(fs);
+ await rt.build(ROOT);
+
+ expect(rt.routeCount()).toBe(1);
+ });
+
+ it('counts index alias as two entries', async () => {
+ const fs = createMockFileSystem(
+ Object.fromEntries([page('app/views/pages/products/index.html.liquid')]),
+ );
+ const rt = new RouteTable(fs);
+ await rt.build(ROOT);
+
+ // products + products/index
+ expect(rt.routeCount()).toBe(2);
+ });
+
+ it('decrements after removeFile', async () => {
+ const fs = createMockFileSystem(
+ Object.fromEntries([
+ page('app/views/pages/a.html.liquid'),
+ page('app/views/pages/b.html.liquid'),
+ ]),
+ );
+ const rt = new RouteTable(fs);
+ await rt.build(ROOT);
+
+ expect(rt.routeCount()).toBe(2);
+ rt.removeFile('file:///project/app/views/pages/a.html.liquid');
+ expect(rt.routeCount()).toBe(1);
+ });
+ });
+
+ describe('large route table', () => {
+ it('handles hundreds of routes and matches correctly', async () => {
+ const files: [string, string][] = [];
+ for (let i = 0; i < 200; i++) {
+ files.push(page(`app/views/pages/section-${i}/page.html.liquid`));
+ }
+ // Add some dynamic routes
+ for (let i = 0; i < 50; i++) {
+ files.push(
+ page(`app/views/pages/api-${i}.html.liquid`, `---\nslug: api/${i}/items/:id\n---\n`),
+ );
+ }
+ const fs = createMockFileSystem(Object.fromEntries(files));
+ const rt = new RouteTable(fs);
+ await rt.build(ROOT);
+
+ expect(rt.routeCount()).toBe(250);
+
+ // Static matches
+ expect(rt.hasMatch('/section-0/page')).toBe(true);
+ expect(rt.hasMatch('/section-199/page')).toBe(true);
+ expect(rt.hasMatch('/section-200/page')).toBe(false);
+
+ // Dynamic matches
+ expect(rt.hasMatch('/api/0/items/42')).toBe(true);
+ expect(rt.hasMatch('/api/49/items/abc')).toBe(true);
+ expect(rt.hasMatch('/api/50/items/1')).toBe(false);
+
+ // Precedence: static more specific than dynamic
+ const matches = rt.match('/api/0/items/42');
+ expect(matches.length).toBe(1);
+ expect(matches[0].slug).toBe('api/0/items/:id');
+ });
+
+ it('updateFile on large table does not corrupt other routes', async () => {
+ const files: [string, string][] = [];
+ for (let i = 0; i < 100; i++) {
+ files.push(page(`app/views/pages/page-${i}.html.liquid`));
+ }
+ const fs = createMockFileSystem(Object.fromEntries(files));
+ const rt = new RouteTable(fs);
+ await rt.build(ROOT);
+
+ expect(rt.routeCount()).toBe(100);
+
+ // Update one page to change its slug
+ rt.updateFile(
+ 'file:///project/app/views/pages/page-50.html.liquid',
+ '---\nslug: new-slug\n---\n',
+ );
+
+ expect(rt.routeCount()).toBe(100);
+ expect(rt.hasMatch('/page-50')).toBe(false);
+ expect(rt.hasMatch('/new-slug')).toBe(true);
+ // Other routes unaffected
+ expect(rt.hasMatch('/page-0')).toBe(true);
+ expect(rt.hasMatch('/page-99')).toBe(true);
+ });
+
+ it('build clears all routes before repopulating', async () => {
+ const files1: [string, string][] = [];
+ for (let i = 0; i < 50; i++) {
+ files1.push(page(`app/views/pages/old-${i}.html.liquid`));
+ }
+ const fs1 = createMockFileSystem(Object.fromEntries(files1));
+ const rt = new RouteTable(fs1);
+ await rt.build(ROOT);
+
+ expect(rt.routeCount()).toBe(50);
+
+ // Rebuild with different files
+ const files2: [string, string][] = [];
+ for (let i = 0; i < 30; i++) {
+ files2.push(page(`app/views/pages/new-${i}.html.liquid`));
+ }
+ const fs2 = createMockFileSystem(Object.fromEntries(files2));
+ const rt2 = new RouteTable(fs2);
+ await rt2.build(ROOT);
+
+ expect(rt2.routeCount()).toBe(30);
+ expect(rt2.hasMatch('/old-0')).toBe(false);
+ expect(rt2.hasMatch('/new-0')).toBe(true);
+ });
+ });
});
diff --git a/packages/platformos-common/src/route-table/RouteTable.ts b/packages/platformos-common/src/route-table/RouteTable.ts
index c1412f2..725c87d 100644
--- a/packages/platformos-common/src/route-table/RouteTable.ts
+++ b/packages/platformos-common/src/route-table/RouteTable.ts
@@ -50,8 +50,10 @@ function extractFrontmatter(source: string): PageFrontmatter | null {
*/
function extractRelativePagePath(uri: string): string | null {
const patterns = [
+ // App-level pages: app/views/pages/ or marketplace_builder/pages/
/\/(app|marketplace_builder)\/(views\/pages|pages)\//,
- /\/(public|private)\/(views\/pages|pages)\//,
+ // Module pages: must be under modules//(public|private)/(views/pages|pages)/
+ /\/modules\/[^/]+\/(public|private)\/(views\/pages|pages)\//,
];
for (const pattern of patterns) {
@@ -255,6 +257,15 @@ export class RouteTable {
return false;
}
+ /** Returns the total number of route entries (including index aliases). */
+ routeCount(): number {
+ let count = 0;
+ for (const entries of this.routes.values()) {
+ count += entries.length;
+ }
+ return count;
+ }
+
allRoutes(): RouteEntry[] {
const all: RouteEntry[] = [];
for (const entries of this.routes.values()) {
@@ -361,15 +372,17 @@ export class RouteTable {
await this.walkDirectory(baseUri.toString(), uris);
}
- // Module pages — discover module names first
+ // Module pages — discover module names first, then walk all in parallel
const moduleNames = await this.discoverModuleNames(rootUri);
+ const moduleWalks: Promise[] = [];
for (const moduleName of moduleNames) {
const modulePaths = getModulePaths(PlatformOSFileType.Page, moduleName);
for (const basePath of modulePaths) {
const baseUri = Utils.joinPath(rootUri, basePath);
- await this.walkDirectory(baseUri.toString(), uris);
+ moduleWalks.push(this.walkDirectory(baseUri.toString(), uris));
}
}
+ await Promise.all(moduleWalks);
return uris;
}
diff --git a/packages/platformos-language-server-common/src/TypeSystem.ts b/packages/platformos-language-server-common/src/TypeSystem.ts
index 58df72a..64f2743 100644
--- a/packages/platformos-language-server-common/src/TypeSystem.ts
+++ b/packages/platformos-language-server-common/src/TypeSystem.ts
@@ -1767,12 +1767,13 @@ function resolveExpressionShape(
if (pseudoType !== undefined && objectMap) {
if (lookup.type !== NodeTypes.String) return undefined;
const propType = inferPseudoTypePropertyType(pseudoType, lookup, objectMap);
+ // inferPseudoTypePropertyType returns PseudoType | ArrayType (string or { kind: 'array' })
if (typeof propType === 'string') {
pseudoType = propType;
shape = resolvedTypeToShape(propType);
- } else if (isShapeType(propType as PseudoType | ArrayType | ShapeType | UnionType)) {
+ } else if (isArrayType(propType)) {
pseudoType = undefined;
- shape = (propType as unknown as ShapeType).shape;
+ shape = resolvedTypeToShape(propType);
} else {
return undefined;
}
diff --git a/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts b/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts
index bd00008..a24b118 100644
--- a/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts
+++ b/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts
@@ -1,10 +1,12 @@
import { findCurrentNode, SourceCodeType } from '@platformos/platformos-check-common';
-import { AbstractFileSystem } from '@platformos/platformos-common';
+import { AbstractFileSystem, DocumentsLocator, RouteTable } from '@platformos/platformos-common';
import { DefinitionLink, DefinitionParams } from 'vscode-languageserver';
import { AugmentedJsonSourceCode, DocumentManager } from '../documents';
+import { SearchPathsLoader } from '../utils/searchPaths';
import { BaseDefinitionProvider } from './BaseDefinitionProvider';
import { PageRouteDefinitionProvider } from './providers/PageRouteDefinitionProvider';
+import { RenderPartialDefinitionProvider } from './providers/RenderPartialDefinitionProvider';
import { TranslationStringDefinitionProvider } from './providers/TranslationStringDefinitionProvider';
export class DefinitionProvider {
@@ -16,6 +18,8 @@ export class DefinitionProvider {
getDefaultLocaleSourceCode: (uri: string) => Promise,
fs?: AbstractFileSystem,
findAppRootURI?: (uri: string) => Promise,
+ documentsLocator?: DocumentsLocator,
+ searchPathsCache?: SearchPathsLoader,
) {
this.providers = [
new TranslationStringDefinitionProvider(documentManager, getDefaultLocaleSourceCode),
@@ -24,6 +28,17 @@ export class DefinitionProvider {
if (fs && findAppRootURI) {
this.pageRouteProvider = new PageRouteDefinitionProvider(documentManager, fs, findAppRootURI);
this.providers.push(this.pageRouteProvider);
+
+ if (documentsLocator && searchPathsCache) {
+ this.providers.push(
+ new RenderPartialDefinitionProvider(
+ documentManager,
+ documentsLocator,
+ searchPathsCache,
+ findAppRootURI,
+ ),
+ );
+ }
}
}
@@ -45,8 +60,12 @@ export class DefinitionProvider {
this.pageRouteProvider?.invalidate();
}
- /** Returns the shared RouteTable, or undefined if route support is not configured. */
- getRouteTable(): import('@platformos/platformos-common').RouteTable | undefined {
+ /**
+ * Returns the shared RouteTable, or undefined if route support is not configured.
+ * When undefined (no fs/findAppRootURI), the check pipeline will build a fresh
+ * RouteTable per run via makeGetRouteTable in context-utils.ts.
+ */
+ getRouteTable(): RouteTable | undefined {
return this.pageRouteProvider?.getRouteTable();
}
diff --git a/packages/platformos-language-server-common/src/definitions/providers/PageRouteDefinitionProvider.spec.ts b/packages/platformos-language-server-common/src/definitions/providers/PageRouteDefinitionProvider.spec.ts
index 3959c8d..7bf06c6 100644
--- a/packages/platformos-language-server-common/src/definitions/providers/PageRouteDefinitionProvider.spec.ts
+++ b/packages/platformos-language-server-common/src/definitions/providers/PageRouteDefinitionProvider.spec.ts
@@ -524,6 +524,29 @@ describe('Module: PageRouteDefinitionProvider', () => {
expect(result).toHaveLength(1);
expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
});
+
+ it('navigates when assign and are both inside the same block tag', async () => {
+ // Regression test: when buildVariableMap skips recursion into block containers
+ // that end after beforeOffset, assigns inside the same block as are missed.
+ setup({
+ 'app/views/pages/about.html.liquid': 'About
',
+ });
+
+ const source =
+ '{% if true %}{% assign url = "/about" %}About{% endif %}';
+ documentManager.open('file:///project/app/views/pages/home.html.liquid', source, 1);
+
+ const urlOffset = source.indexOf('{{ url }}');
+ const params: DefinitionParams = {
+ textDocument: { uri: 'file:///project/app/views/pages/home.html.liquid' },
+ position: { line: 0, character: urlOffset + 3 }, // Inside {{ url }}
+ };
+
+ const result = await provider.definitions(params);
+ assert(result);
+ expect(result).toHaveLength(1);
+ expect(result[0].targetUri).toBe('file:///project/app/views/pages/about.html.liquid');
+ });
});
describe('format-aware go-to-definition', () => {
diff --git a/packages/platformos-language-server-common/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts b/packages/platformos-language-server-common/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts
new file mode 100644
index 0000000..8568486
--- /dev/null
+++ b/packages/platformos-language-server-common/src/definitions/providers/RenderPartialDefinitionProvider.spec.ts
@@ -0,0 +1,191 @@
+import { describe, it, expect, assert } from 'vitest';
+import { toLiquidHtmlAST } from '@platformos/liquid-html-parser';
+import { findCurrentNode } from '@platformos/platformos-check-common';
+import { MockFileSystem } from '@platformos/platformos-check-common/src/test';
+import { DocumentsLocator } from '@platformos/platformos-common';
+import { DefinitionParams, Position } from 'vscode-languageserver-protocol';
+import { DocumentManager } from '../../documents';
+import { SearchPathsLoader } from '../../utils/searchPaths';
+import { RenderPartialDefinitionProvider } from './RenderPartialDefinitionProvider';
+
+const rootUri = 'file:///project';
+const uriString = 'file:///project/app/views/pages/index.liquid';
+
+function setup(files: Record) {
+ const documentManager = new DocumentManager();
+ const mockFs = new MockFileSystem(files);
+ const provider = new RenderPartialDefinitionProvider(
+ documentManager,
+ new DocumentsLocator(mockFs),
+ new SearchPathsLoader(mockFs),
+ async () => rootUri,
+ );
+ return { documentManager, provider };
+}
+
+async function getDefinitions(source: string, cursorOffset: number, files: Record) {
+ const { documentManager, provider } = setup(files);
+ documentManager.open(uriString, source, 1);
+
+ const ast = toLiquidHtmlAST(source);
+ const [node, ancestors] = findCurrentNode(ast, cursorOffset);
+ const params: DefinitionParams = {
+ textDocument: { uri: uriString },
+ position: Position.create(0, cursorOffset),
+ };
+
+ return provider.definitions(params, node, ancestors);
+}
+
+describe('RenderPartialDefinitionProvider', () => {
+ describe('render tag', () => {
+ it('should resolve render partials', async () => {
+ const result = await getDefinitions("{% render 'card' %}", 12, {
+ 'project/app/views/partials/card.liquid': 'card content',
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].targetUri).toBe('file:///project/app/views/partials/card.liquid');
+ });
+
+ it('should NOT use search paths for regular render tags', async () => {
+ const result = await getDefinitions("{% render 'card' %}", 12, {
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'project/app/views/partials/theme/dress/card.liquid': 'dress card',
+ });
+
+ expect(result).toHaveLength(0);
+ });
+ });
+
+ describe('theme_render_rc tag', () => {
+ it('should resolve via theme_search_paths', async () => {
+ const result = await getDefinitions("{% theme_render_rc 'card' %}", 20, {
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'project/app/views/partials/theme/dress/card.liquid': 'dress card',
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].targetUri).toBe(
+ 'file:///project/app/views/partials/theme/dress/card.liquid',
+ );
+ });
+
+ it('should fallback to standard resolution when no config exists', async () => {
+ const result = await getDefinitions("{% theme_render_rc 'card' %}", 20, {
+ 'project/app/views/partials/card.liquid': 'default card',
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].targetUri).toBe('file:///project/app/views/partials/card.liquid');
+ });
+
+ it('should resolve inside {% liquid %} blocks', async () => {
+ const source = `{% liquid
+ theme_render_rc 'components/atoms/heading', content: 'text'
+%}`;
+ const offset = source.indexOf('components/atoms/heading');
+ const { documentManager, provider } = setup({
+ 'project/app/config.yml': "theme_search_paths:\n - ''\n - modules/components",
+ 'project/modules/components/public/views/partials/components/atoms/heading.liquid':
+ 'heading',
+ });
+ documentManager.open(uriString, source, 1);
+
+ const ast = toLiquidHtmlAST(source);
+ const [node, ancestors] = findCurrentNode(ast, offset);
+ const params: DefinitionParams = {
+ textDocument: { uri: uriString },
+ position: Position.create(1, 20),
+ };
+
+ const result = await provider.definitions(params, node, ancestors);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].targetUri).toBe(
+ 'file:///project/modules/components/public/views/partials/components/atoms/heading.liquid',
+ );
+ });
+ });
+
+ describe('function tag', () => {
+ it('should resolve function partials', async () => {
+ const result = await getDefinitions("{% function result = 'commands/apply' %}", 24, {
+ 'project/app/lib/commands/apply.liquid': 'apply content',
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].targetUri).toBe('file:///project/app/lib/commands/apply.liquid');
+ });
+ });
+
+ describe('graphql tag', () => {
+ it('should resolve graphql references', async () => {
+ const result = await getDefinitions("{% graphql g = 'users/search' %}", 18, {
+ 'project/app/graphql/users/search.graphql': 'query { }',
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].targetUri).toBe('file:///project/app/graphql/users/search.graphql');
+ });
+ });
+
+ describe('non-matching nodes', () => {
+ it('should return empty for non-string nodes', async () => {
+ const result = await getDefinitions("{% assign x = 'hello' %}", 3, {});
+
+ expect(result).toHaveLength(0);
+ });
+ });
+
+ describe('search path cache invalidation', () => {
+ it('returns stale result when cache is not invalidated after config change', async () => {
+ // Regression test: SearchPathsLoader caches results across calls.
+ // Without explicit invalidate(), a config change is not picked up.
+ const files: Record = {
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'project/app/views/partials/theme/dress/card.liquid': 'dress card',
+ 'project/app/views/partials/theme/simple/card.liquid': 'simple card',
+ };
+ const mockFs = new MockFileSystem(files);
+ const searchPathsCache = new SearchPathsLoader(mockFs);
+ const documentManager = new DocumentManager();
+ const provider = new RenderPartialDefinitionProvider(
+ documentManager,
+ new DocumentsLocator(mockFs),
+ searchPathsCache,
+ async () => rootUri,
+ );
+
+ const source = "{% theme_render_rc 'card' %}";
+ const offset = source.indexOf("'card'") + 1;
+ documentManager.open(uriString, source, 1);
+
+ const ast = toLiquidHtmlAST(source);
+ const [node, ancestors] = findCurrentNode(ast, offset);
+ const params: DefinitionParams = {
+ textDocument: { uri: uriString },
+ position: Position.create(0, offset),
+ };
+
+ // First call: populates the cache with 'theme/dress'
+ const result1 = await provider.definitions(params, node, ancestors);
+ assert(result1.length === 1);
+ expect(result1[0].targetUri).toContain('theme/dress/card.liquid');
+
+ // Simulate config.yml change on disk (mutate mockApp in-place)
+ files['project/app/config.yml'] = 'theme_search_paths:\n - theme/simple';
+
+ // Without invalidation: cache still returns 'theme/dress' result
+ const result2 = await provider.definitions(params, node, ancestors);
+ assert(result2.length === 1);
+ expect(result2[0].targetUri).toContain('theme/dress/card.liquid');
+
+ // After invalidation: re-reads config and returns 'theme/simple' result
+ searchPathsCache.invalidate();
+ const result3 = await provider.definitions(params, node, ancestors);
+ assert(result3.length === 1);
+ expect(result3[0].targetUri).toContain('theme/simple/card.liquid');
+ });
+ });
+});
diff --git a/packages/platformos-language-server-common/src/definitions/providers/RenderPartialDefinitionProvider.ts b/packages/platformos-language-server-common/src/definitions/providers/RenderPartialDefinitionProvider.ts
new file mode 100644
index 0000000..df05c75
--- /dev/null
+++ b/packages/platformos-language-server-common/src/definitions/providers/RenderPartialDefinitionProvider.ts
@@ -0,0 +1,70 @@
+import { LiquidHtmlNode, LiquidString, LiquidTag, NodeTypes } from '@platformos/liquid-html-parser';
+import { DocumentsLocator, DocumentType } from '@platformos/platformos-common';
+import {
+ DefinitionParams,
+ DefinitionLink,
+ Range,
+ LocationLink,
+} from 'vscode-languageserver-protocol';
+import { URI } from 'vscode-uri';
+import { DocumentManager } from '../../documents';
+import { BaseDefinitionProvider } from '../BaseDefinitionProvider';
+import { SearchPathsLoader } from '../../utils/searchPaths';
+
+const TAG_MARKUP_TYPE: Record = {
+ render: NodeTypes.RenderMarkup,
+ include: NodeTypes.RenderMarkup,
+ theme_render_rc: NodeTypes.RenderMarkup,
+ function: NodeTypes.FunctionMarkup,
+ graphql: NodeTypes.GraphQLMarkup,
+};
+
+export class RenderPartialDefinitionProvider implements BaseDefinitionProvider {
+ constructor(
+ private documentManager: DocumentManager,
+ private documentsLocator: DocumentsLocator,
+ private searchPathsCache: SearchPathsLoader,
+ private findAppRootURI: (uri: string) => Promise,
+ ) {}
+
+ async definitions(
+ params: DefinitionParams,
+ node: LiquidHtmlNode,
+ ancestors: LiquidHtmlNode[],
+ ): Promise {
+ if (node.type !== NodeTypes.String) return [];
+
+ const markup = ancestors.at(-1);
+ const tag = ancestors.at(-2);
+ if (!markup || !tag || tag.type !== NodeTypes.LiquidTag) return [];
+
+ const expectedMarkupType = TAG_MARKUP_TYPE[(tag as LiquidTag).name];
+ if (expectedMarkupType === undefined || markup.type !== expectedMarkupType) return [];
+
+ const rootUri = await this.findAppRootURI(params.textDocument.uri);
+ if (!rootUri) return [];
+
+ const root = URI.parse(rootUri);
+ const searchPaths = await this.searchPathsCache.get(root);
+ const docType = (tag as LiquidTag).name as DocumentType;
+ const fileUri = await this.documentsLocator.locate(
+ root,
+ docType,
+ (node as LiquidString).value,
+ searchPaths,
+ );
+ if (!fileUri) return [];
+
+ const sourceCode = this.documentManager.get(params.textDocument.uri);
+ if (!sourceCode) return [];
+
+ const doc = sourceCode.textDocument;
+ const originRange = Range.create(
+ doc.positionAt(node.position.start),
+ doc.positionAt(node.position.end),
+ );
+ const targetRange = Range.create(0, 0, 0, 0);
+
+ return [LocationLink.create(fileUri, targetRange, targetRange, originRange)];
+ }
+}
diff --git a/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts b/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts
index 5d8be5b..597b6dc 100644
--- a/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts
+++ b/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts
@@ -3,28 +3,35 @@ import { DocumentManager } from '../documents';
import { DocumentLinksProvider } from './DocumentLinksProvider';
import { DocumentsLocator, TranslationProvider } from '@platformos/platformos-common';
import { MockFileSystem } from '@platformos/platformos-check-common/src/test';
+import { SearchPathsLoader } from '../utils/searchPaths';
+
+function makeProvider(
+ documentManager: DocumentManager,
+ rootUri: string,
+ mockFs: MockFileSystem,
+): DocumentLinksProvider {
+ return new DocumentLinksProvider(
+ documentManager,
+ async () => rootUri,
+ new DocumentsLocator(mockFs),
+ new TranslationProvider(mockFs),
+ new SearchPathsLoader(mockFs),
+ );
+}
describe('DocumentLinksProvider', () => {
let documentManager: DocumentManager;
let documentLinksProvider: DocumentLinksProvider;
- let documentsLocator: DocumentsLocator;
- let fs: MockFileSystem;
let rootUri: string;
let uriString: string;
beforeEach(() => {
documentManager = new DocumentManager();
- fs = new MockFileSystem({
+ const fs = new MockFileSystem({
'path/to/project/app/lib/commands/apply.liquid': 'apply content',
'path/to/project/app/views/apply_view.liquid': 'apply view content',
});
- documentsLocator = new DocumentsLocator(fs);
- documentLinksProvider = new DocumentLinksProvider(
- documentManager,
- async () => rootUri,
- documentsLocator,
- new TranslationProvider(fs),
- );
+ documentLinksProvider = makeProvider(documentManager, 'file:///path/to/project', fs);
});
it('should return an empty array for non-LiquidHtml documents', async () => {
@@ -63,4 +70,118 @@ describe('DocumentLinksProvider', () => {
expect(result[i].target).toBe(expectedUrls[i]);
}
});
+
+ describe('theme_render_rc', () => {
+ it('should resolve theme_render_rc partials via theme_search_paths', async () => {
+ rootUri = 'file:///project';
+ uriString = 'file:///project/app/views/pages/index.liquid';
+
+ const mockFs = new MockFileSystem({
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
+ 'project/app/views/partials/theme/dress/card.liquid': 'dress card',
+ 'project/app/views/partials/theme/simple/footer.liquid': 'simple footer',
+ });
+
+ const provider = makeProvider(documentManager, rootUri, mockFs);
+
+ documentManager.open(
+ uriString,
+ "{% theme_render_rc 'card' %} {% theme_render_rc 'footer' %}",
+ 1,
+ );
+
+ const result = await provider.documentLinks(uriString);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].target).toBe('file:///project/app/views/partials/theme/dress/card.liquid');
+ expect(result[1].target).toBe(
+ 'file:///project/app/views/partials/theme/simple/footer.liquid',
+ );
+ });
+
+ it('should fallback to standard resolution when no config exists', async () => {
+ rootUri = 'file:///project';
+ uriString = 'file:///project/app/views/pages/index.liquid';
+
+ const mockFs = new MockFileSystem({
+ 'project/app/views/partials/card.liquid': 'default card',
+ });
+
+ const provider = makeProvider(documentManager, rootUri, mockFs);
+
+ documentManager.open(uriString, "{% theme_render_rc 'card' %}", 1);
+
+ const result = await provider.documentLinks(uriString);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].target).toBe('file:///project/app/views/partials/card.liquid');
+ });
+
+ it('should resolve theme_render_rc with Liquid wildcard paths', async () => {
+ rootUri = 'file:///project';
+ uriString = 'file:///project/app/views/pages/index.liquid';
+
+ const mockFs = new MockFileSystem({
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/{{ context.constants.THEME }}',
+ 'project/app/views/partials/theme/custom/hero.liquid': 'custom hero',
+ });
+
+ const provider = makeProvider(documentManager, rootUri, mockFs);
+
+ documentManager.open(uriString, "{% theme_render_rc 'hero' %}", 1);
+
+ const result = await provider.documentLinks(uriString);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].target).toBe('file:///project/app/views/partials/theme/custom/hero.liquid');
+ });
+
+ it('should not use search paths for regular render tags', async () => {
+ rootUri = 'file:///project';
+ uriString = 'file:///project/app/views/pages/index.liquid';
+
+ const mockFs = new MockFileSystem({
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/dress',
+ 'project/app/views/partials/card.liquid': 'default card',
+ });
+
+ const provider = makeProvider(documentManager, rootUri, mockFs);
+
+ documentManager.open(uriString, "{% render 'card' %}", 1);
+
+ const result = await provider.documentLinks(uriString);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].target).toBe('file:///project/app/views/partials/card.liquid');
+ });
+
+ it('should pick up new config after cache invalidation', async () => {
+ rootUri = 'file:///project';
+ uriString = 'file:///project/app/views/pages/index.liquid';
+
+ const initialFiles: Record = {
+ 'project/app/views/partials/card.liquid': 'default card',
+ 'project/app/views/partials/theme/new/card.liquid': 'new card',
+ };
+ const mockFs = new MockFileSystem(initialFiles);
+ const provider = makeProvider(documentManager, rootUri, mockFs);
+
+ documentManager.open(uriString, "{% theme_render_rc 'card' %}", 1);
+
+ const result1 = await provider.documentLinks(uriString);
+ expect(result1).toHaveLength(1);
+ expect(result1[0].target).toBe('file:///project/app/views/partials/card.liquid');
+
+ // Simulate config.yml being created with search paths — new provider with new fs
+ const updatedFs = new MockFileSystem({
+ ...initialFiles,
+ 'project/app/config.yml': 'theme_search_paths:\n - theme/new',
+ });
+ const provider2 = makeProvider(documentManager, rootUri, updatedFs);
+
+ const result2 = await provider2.documentLinks(uriString);
+ expect(result2).toHaveLength(1);
+ expect(result2[0].target).toBe('file:///project/app/views/partials/theme/new/card.liquid');
+ });
+ });
});
diff --git a/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.ts b/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.ts
index 292cda2..0f4348e 100644
--- a/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.ts
+++ b/packages/platformos-language-server-common/src/documentLinks/DocumentLinksProvider.ts
@@ -1,13 +1,14 @@
-import { LiquidHtmlNode, LiquidString, NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
+import { LiquidHtmlNode, LiquidString, NodeTypes } from '@platformos/liquid-html-parser';
import { SourceCodeType } from '@platformos/platformos-check-common';
import { DocumentLink, Range } from 'vscode-languageserver';
import { TextDocument } from 'vscode-languageserver-textdocument';
-import { URI, Utils } from 'vscode-uri';
+import { URI } from 'vscode-uri';
import { visit, Visitor } from '@platformos/platformos-check-common';
import { DocumentManager } from '../documents';
import { FindAppRootURI } from '../internal-types';
-import { DocumentsLocator, TranslationProvider } from '@platformos/platformos-common';
+import { DocumentsLocator, DocumentType, TranslationProvider } from '@platformos/platformos-common';
+import { SearchPathsLoader } from '../utils/searchPaths';
export class DocumentLinksProvider {
constructor(
@@ -15,6 +16,7 @@ export class DocumentLinksProvider {
private findAppRootURI: FindAppRootURI,
private documentsLocator: DocumentsLocator,
private translationProvider: TranslationProvider,
+ private searchPathsCache: SearchPathsLoader,
) {}
async documentLinks(uriString: string): Promise {
@@ -32,11 +34,15 @@ export class DocumentLinksProvider {
return [];
}
+ const root = URI.parse(rootUri);
+ const searchPaths = await this.searchPathsCache.get(root);
+
const visitor = documentLinksVisitor(
sourceCode.textDocument,
- URI.parse(rootUri),
+ root,
this.documentsLocator,
this.translationProvider,
+ searchPaths,
);
return visit(sourceCode.ast, visitor);
}
@@ -47,43 +53,28 @@ function documentLinksVisitor(
root: URI,
documentsLocator: DocumentsLocator,
translationProvider: TranslationProvider,
+ searchPaths: string[] | null,
): Visitor {
return {
async LiquidTag(node) {
- if (
- (node.name === 'render' || node.name === 'include') &&
- typeof node.markup !== 'string' &&
- isLiquidString(node.markup.partial)
- ) {
- const partial = node.markup.partial;
- return DocumentLink.create(
- range(textDocument, partial),
- await documentsLocator.locate(root, node.name, partial.value),
- );
- }
+ const markup = node.markup;
+ if (typeof markup === 'string' || markup === null) return;
+
+ const name = node.name as DocumentType;
- if (
- node.name === 'function' &&
- typeof node.markup !== 'string' &&
- isLiquidString(node.markup.partial)
- ) {
- const partial = node.markup.partial;
+ // render, include, function, theme_render_rc all have a .partial field
+ if ('partial' in markup && isLiquidString(markup.partial)) {
return DocumentLink.create(
- range(textDocument, partial),
- await documentsLocator.locate(root, node.name, partial.value),
+ range(textDocument, markup.partial),
+ await documentsLocator.locate(root, name, markup.partial.value, searchPaths),
);
}
- if (
- node.name === 'graphql' &&
- typeof node.markup !== 'string' &&
- 'graphql' in node.markup &&
- isLiquidString(node.markup.graphql)
- ) {
- const snippet = node.markup.graphql;
+ // graphql has a .graphql field
+ if ('graphql' in markup && isLiquidString(markup.graphql)) {
return DocumentLink.create(
- range(textDocument, snippet),
- await documentsLocator.locate(root, node.name, snippet.value),
+ range(textDocument, markup.graphql),
+ await documentsLocator.locate(root, name, markup.graphql.value),
);
}
},
diff --git a/packages/platformos-language-server-common/src/server/CachedFileSystem.ts b/packages/platformos-language-server-common/src/server/CachedFileSystem.ts
index 72f94d3..980113e 100644
--- a/packages/platformos-language-server-common/src/server/CachedFileSystem.ts
+++ b/packages/platformos-language-server-common/src/server/CachedFileSystem.ts
@@ -6,7 +6,12 @@ export class CachedFileSystem implements AbstractFileSystem {
stat: Cached;
constructor(fs: AbstractFileSystem) {
- this.readFile = cachedByUri(fs.readFile.bind(fs));
+ this.readFile = cachedByUri(
+ fs.readFile.bind(fs),
+ // app/config.yml can change externally (e.g. vim) without a file watcher
+ // notification. Always read it fresh — it's small and rarely accessed.
+ (uri) => uri.endsWith('/app/config.yml'),
+ );
this.readDirectory = cachedByUri(fs.readDirectory.bind(fs));
this.stat = cachedByUri(fs.stat.bind(fs));
}
@@ -17,10 +22,14 @@ interface Cached Promise, T = ReturnType> {
invalidate(uri: string): void;
}
-function cachedByUri(fn: (uri: string) => Promise): Cached {
+function cachedByUri(
+ fn: (uri: string) => Promise,
+ skipCache?: (uri: string) => boolean,
+): Cached {
const cache = new Map>();
function cached(uri: string) {
+ if (skipCache?.(uri)) return fn(uri);
if (!cache.has(uri)) {
// I'm intentionally leaving this comment here for debugging purposes :)
// console.error('cache miss', fn.name, uri);
diff --git a/packages/platformos-language-server-common/src/server/startServer.ts b/packages/platformos-language-server-common/src/server/startServer.ts
index fc5b535..a99280c 100644
--- a/packages/platformos-language-server-common/src/server/startServer.ts
+++ b/packages/platformos-language-server-common/src/server/startServer.ts
@@ -44,6 +44,7 @@ import {
AppGraphRootRequest,
} from '../types';
import { debounce } from '../utils';
+import { SearchPathsLoader } from '../utils/searchPaths';
import { VERSION } from '../version';
import { CachedFileSystem } from './CachedFileSystem';
import { Configuration, INCLUDE_FILES_FROM_DISK } from './Configuration';
@@ -55,6 +56,13 @@ import { URI } from 'vscode-uri';
const defaultLogger = () => {};
+/**
+ * When a file-watcher batch contains this many page-file changes, we
+ * assume a bulk operation (git checkout, branch switch, stash pop) and
+ * fully rebuild the route table instead of applying incremental updates.
+ */
+const BULK_PAGE_CHANGE_THRESHOLD = 10;
+
/**
* The `git:` VFS does not support the `fs.readDirectory` call and makes most things break.
* `git` URIs are the ones you'd encounter when doing a git diff in VS Code. They're not
@@ -108,11 +116,13 @@ export function startServer(
const diagnosticsManager = new DiagnosticsManager(connection);
const documentsLocator = new DocumentsLocator(fs);
const translationProvider = new TranslationProvider(fs);
+ const searchPathsCache = new SearchPathsLoader(fs);
const documentLinksProvider = new DocumentLinksProvider(
documentManager,
findAppRootURI,
documentsLocator,
translationProvider,
+ searchPathsCache,
);
const codeActionsProvider = new CodeActionsProvider(documentManager, diagnosticsManager);
const onTypeFormattingProvider = new OnTypeFormattingProvider(
@@ -221,6 +231,8 @@ export function startServer(
getDefaultLocaleSourceCode,
fs,
findAppRootURI,
+ documentsLocator,
+ searchPathsCache,
);
const jsonLanguageService = new JSONLanguageService(documentManager, jsonValidationSet);
const cssLanguageService = new CSSLanguageService(documentManager);
@@ -348,6 +360,9 @@ export function startServer(
{
globPattern: '**/*.css',
},
+ {
+ globPattern: '**/app/config.yml',
+ },
],
});
});
@@ -410,6 +425,15 @@ export function startServer(
connection.onDidSaveTextDocument(async (params) => {
if (hasUnsupportedDocument(params)) return;
const { uri } = params.textDocument;
+
+ // onDidChangeWatchedFiles also fires for in-editor saves, but it arrives after
+ // onDidSaveTextDocument. Invalidate the search-paths cache here immediately so
+ // that go-to-definition requests triggered by the same save don't see stale data
+ // while waiting for the file-watcher notification.
+ if (uri.endsWith('/app/config.yml')) {
+ searchPathsCache.invalidate();
+ }
+
if (await configuration.shouldCheckOnSave()) {
runChecks([uri]);
}
@@ -550,7 +574,7 @@ export function startServer(
// individual onDidChangeTextDocument events. VS Code reports branch-switch changes
// as FileChangeType.Changed, so we count all change types.
const bulkPageChanges = params.changes.filter((c) => isPage(c.uri));
- if (bulkPageChanges.length >= 3) {
+ if (bulkPageChanges.length >= BULK_PAGE_CHANGE_THRESHOLD) {
definitionsProvider.invalidateRouteTable();
}
@@ -562,6 +586,21 @@ export function startServer(
continue;
}
+ // app/config.yml changes should clear expanded search paths cache
+ // and force re-check of all open documents.
+ // Note: readFile for config.yml bypasses the CachedFileSystem cache,
+ // so we don't need to invalidate it — reads are always fresh.
+ if (change.uri.endsWith('/app/config.yml')) {
+ documentsLocator.clearExpandedPathsCache();
+ searchPathsCache.invalidate();
+
+ // Ensure open liquid files are re-checked with the new search paths
+ for (const doc of documentManager.openDocuments) {
+ triggerUris.push(doc.uri);
+ }
+ continue;
+ }
+
// Rename cache invalidation is handled by onDidRenameFiles
if (documentManager.hasRecentRename(change.uri)) {
documentManager.clearRecentRename(change.uri);
diff --git a/packages/platformos-language-server-common/src/utils/searchPaths.spec.ts b/packages/platformos-language-server-common/src/utils/searchPaths.spec.ts
new file mode 100644
index 0000000..552f62e
--- /dev/null
+++ b/packages/platformos-language-server-common/src/utils/searchPaths.spec.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, vi } from 'vitest';
+import { URI } from 'vscode-uri';
+import { SearchPathsLoader } from './searchPaths';
+
+const rootUri = URI.parse('file:///project');
+const configUri = 'file:///project/app/config.yml';
+
+function createMockFs(configContent: string | null) {
+ return {
+ readFile: vi.fn(async (uri: string) => {
+ if (uri === configUri && configContent !== null) return configContent;
+ throw new Error(`File not found: ${uri}`);
+ }),
+ readDirectory: vi.fn(async () => []),
+ stat: vi.fn(async () => ({ type: 1, size: 0 })),
+ };
+}
+
+describe('SearchPathsLoader', () => {
+ it('should cache results across calls for the same root', async () => {
+ const fs = createMockFs('theme_search_paths:\n - theme/dress');
+ const loader = new SearchPathsLoader(fs);
+
+ const result1 = await loader.get(rootUri);
+ expect(result1).toEqual(['theme/dress']);
+
+ const result2 = await loader.get(rootUri);
+ expect(result2).toEqual(['theme/dress']);
+
+ // readFile should only be called once — second call served from cache
+ expect(fs.readFile).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return fresh results after invalidate()', async () => {
+ let configContent = 'theme_search_paths:\n - theme/dress';
+ const fs = {
+ readFile: vi.fn(async (uri: string) => {
+ if (uri === configUri) return configContent;
+ throw new Error(`File not found: ${uri}`);
+ }),
+ readDirectory: vi.fn(async () => []),
+ stat: vi.fn(async () => ({ type: 1, size: 0 })),
+ };
+ const loader = new SearchPathsLoader(fs);
+
+ const result1 = await loader.get(rootUri);
+ expect(result1).toEqual(['theme/dress']);
+
+ // Config changes externally
+ configContent = 'theme_search_paths:\n - theme/simple';
+
+ // Without invalidation, still returns cached result
+ const result2 = await loader.get(rootUri);
+ expect(result2).toEqual(['theme/dress']);
+
+ // After invalidation, reads fresh
+ loader.invalidate();
+ const result3 = await loader.get(rootUri);
+ expect(result3).toEqual(['theme/simple']);
+
+ expect(fs.readFile).toHaveBeenCalledTimes(2);
+ });
+
+ it('should return null when config has no search paths', async () => {
+ const fs = createMockFs('some_other_key: value');
+ const loader = new SearchPathsLoader(fs);
+
+ const result = await loader.get(rootUri);
+ expect(result).toBeNull();
+ });
+
+ it('should return null when config.yml does not exist', async () => {
+ const fs = createMockFs(null);
+ const loader = new SearchPathsLoader(fs);
+
+ const result = await loader.get(rootUri);
+ expect(result).toBeNull();
+ });
+
+ it('should cache per root URI independently', async () => {
+ const root2 = URI.parse('file:///other-project');
+ const config2Uri = 'file:///other-project/app/config.yml';
+ const fs = {
+ readFile: vi.fn(async (uri: string) => {
+ if (uri === configUri) return 'theme_search_paths:\n - theme/a';
+ if (uri === config2Uri) return 'theme_search_paths:\n - theme/b';
+ throw new Error(`File not found: ${uri}`);
+ }),
+ readDirectory: vi.fn(async () => []),
+ stat: vi.fn(async () => ({ type: 1, size: 0 })),
+ };
+ const loader = new SearchPathsLoader(fs);
+
+ const result1 = await loader.get(rootUri);
+ const result2 = await loader.get(root2);
+
+ expect(result1).toEqual(['theme/a']);
+ expect(result2).toEqual(['theme/b']);
+ expect(fs.readFile).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/platformos-language-server-common/src/utils/searchPaths.ts b/packages/platformos-language-server-common/src/utils/searchPaths.ts
new file mode 100644
index 0000000..e7d7496
--- /dev/null
+++ b/packages/platformos-language-server-common/src/utils/searchPaths.ts
@@ -0,0 +1,32 @@
+import { AbstractFileSystem, loadSearchPaths } from '@platformos/platformos-common';
+import { URI } from 'vscode-uri';
+
+/**
+ * Cached loader for theme_search_paths from app/config.yml.
+ *
+ * Caches per root URI so that repeated calls (document links, definitions,
+ * checks) within the same editor session don't re-read and re-parse the
+ * config file on every invocation.
+ *
+ * Call `invalidate()` when app/config.yml changes on disk — the file watcher
+ * in startServer.ts handles this. This is the only reliable invalidation
+ * point because config.yml changes may come from external tools (vim, CLI)
+ * that don't trigger editor-level change events.
+ */
+export class SearchPathsLoader {
+ private cache = new Map>();
+
+ constructor(private fs: AbstractFileSystem) {}
+
+ get(rootUri: URI): Promise {
+ const key = rootUri.toString();
+ if (!this.cache.has(key)) {
+ this.cache.set(key, loadSearchPaths(this.fs, rootUri));
+ }
+ return this.cache.get(key)!;
+ }
+
+ invalidate(): void {
+ this.cache.clear();
+ }
+}
diff --git a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts
index 7f15956..d0da267 100644
--- a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts
+++ b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts
@@ -188,7 +188,8 @@ function printNamedLiquidBlockStart(
}
case NamedTags.include:
- case NamedTags.render: {
+ case NamedTags.render:
+ case NamedTags.theme_render_rc: {
const markup = node.markup;
const trailingWhitespace =
markup.args.length > 0 || (markup.variable && markup.alias) ? line : ' ';
@@ -287,8 +288,7 @@ function printNamedLiquidBlockStart(
return tag(trailingWhitespace);
}
case NamedTags.include_form:
- case NamedTags.spam_protection:
- case NamedTags.theme_render_rc: {
+ case NamedTags.spam_protection: {
const trailingWhitespace = node.markup.args.length > 0 ? line : ' ';
return tag(trailingWhitespace);
}