Skip to content

Commit 2db3047

Browse files
authoredFeb 14, 2025··
Support render param completion using LiquidDocs (#774)

14 files changed

+275
-39
lines changed
 

‎.changeset/small-timers-kick.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
'@shopify/liquid-html-parser': minor
4+
---
5+
6+
Support `render` param completion based on liquid docs
7+
8+
- If you defined liquid doc parameters on a snippet, they will appear as completion options
9+
for parameters when rendered by a `render` tag.

‎packages/liquid-html-parser/grammar/liquid-html.ohm

+10-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ Liquid <: Helpers {
141141
liquidTagInclude = liquidTagRule<"include", liquidTagRenderMarkup>
142142
liquidTagRender = liquidTagRule<"render", liquidTagRenderMarkup>
143143
liquidTagRenderMarkup =
144-
snippetExpression renderVariableExpression? renderAliasExpression? (argumentSeparatorOptionalComma tagArguments) (space* ",")? space*
144+
snippetExpression renderVariableExpression? renderAliasExpression? renderArguments
145+
146+
renderArguments = (argumentSeparatorOptionalComma tagArguments) (space* ",")? space*
147+
completionModeRenderArguments = (argumentSeparatorOptionalComma tagArguments) (space* ",")? space* (argumentSeparator? liquidVariableLookup<delimTag> space*)?
145148
snippetExpression = liquidString<delimTag> | variableSegmentAsLookup
146149
renderVariableExpression = space+ ("for" | "with") space+ liquidExpression<delimTag>
147150
renderAliasExpression = space+ "as" space+ variableSegment
@@ -554,6 +557,8 @@ WithPlaceholderLiquid <: Liquid {
554557
liquidFilter<delim> := space* "|" space* identifier (space* ":" space* filterArguments<delim> (space* ",")?)?
555558
liquidTagContentForMarkup :=
556559
contentForType (argumentSeparatorOptionalComma completionModeContentForTagArgument) (space* ",")? space*
560+
liquidTagRenderMarkup :=
561+
snippetExpression renderVariableExpression? renderAliasExpression? completionModeRenderArguments
557562
liquidTagName := (letter | "█") (alnum | "_")*
558563
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
559564
}
@@ -562,6 +567,8 @@ WithPlaceholderLiquidStatement <: LiquidStatement {
562567
liquidFilter<delim> := space* "|" space* identifier (space* ":" space* filterArguments<delim> (space* ",")?)?
563568
liquidTagContentForMarkup :=
564569
contentForType (argumentSeparatorOptionalComma completionModeContentForTagArgument) (space* ",")? space*
570+
liquidTagRenderMarkup :=
571+
snippetExpression renderVariableExpression? renderAliasExpression? completionModeRenderArguments
565572
liquidTagName := (letter | "█") (alnum | "_")*
566573
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
567574
}
@@ -570,6 +577,8 @@ WithPlaceholderLiquidHTML <: LiquidHTML {
570577
liquidFilter<delim> := space* "|" space* identifier (space* ":" space* filterArguments<delim> (space* ",")?)?
571578
liquidTagContentForMarkup :=
572579
contentForType (argumentSeparatorOptionalComma completionModeContentForTagArgument) (space* ",")? space*
580+
liquidTagRenderMarkup :=
581+
snippetExpression renderVariableExpression? renderAliasExpression? completionModeRenderArguments
573582
liquidTagName := (letter | "█") (alnum | "_")*
574583
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
575584
leadingTagNameTextNode := (letter | "█") (alnum | "-" | ":" | "█")*

‎packages/liquid-html-parser/src/stage-1-cst.spec.ts

+15-6
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import {
66
LiquidCST,
77
ConcreteLiquidTagLiquid,
88
} from './stage-1-cst';
9-
import { BLOCKS, VOID_ELEMENTS } from './grammar';
10-
import { NamedTags } from './types';
9+
import { VOID_ELEMENTS } from './grammar';
1110
import { deepGet } from './utils';
1211

1312
describe('Unit: Stage 1 (CST)', () => {
@@ -561,11 +560,11 @@ describe('Unit: Stage 1 (CST)', () => {
561560
expectPath(cst, '0.markup.variable').to.equal(null);
562561
}
563562
expectPath(cst, '0.markup.alias').to.equal(alias);
564-
expectPath(cst, '0.markup.args').to.have.lengthOf(namedArguments.length);
563+
expectPath(cst, '0.markup.renderArguments').to.have.lengthOf(namedArguments.length);
565564
namedArguments.forEach(({ name, valueType }, i) => {
566-
expectPath(cst, `0.markup.args.${i}.type`).to.equal('NamedArgument');
567-
expectPath(cst, `0.markup.args.${i}.name`).to.equal(name);
568-
expectPath(cst, `0.markup.args.${i}.value.type`).to.equal(valueType);
565+
expectPath(cst, `0.markup.renderArguments.${i}.type`).to.equal('NamedArgument');
566+
expectPath(cst, `0.markup.renderArguments.${i}.name`).to.equal(name);
567+
expectPath(cst, `0.markup.renderArguments.${i}.value.type`).to.equal(valueType);
569568
});
570569
expectPath(cst, '0.whitespaceStart').to.equal(null);
571570
expectPath(cst, '0.whitespaceEnd').to.equal('-');
@@ -1786,6 +1785,16 @@ describe('Unit: Stage 1 (CST)', () => {
17861785
expectPath(cst, '0.markup.args.0.type').to.equal('NamedArgument');
17871786
expectPath(cst, '0.markup.args.1.type').to.equal('VariableLookup');
17881787
});
1788+
1789+
it('should parse incomplete parameters for render tags', () => {
1790+
const toCST = (source: string) => toLiquidHtmlCST(source, { mode: 'completion' });
1791+
1792+
cst = toCST(`{% render "example-snippet", id: 2, foo█ %}`);
1793+
1794+
expectPath(cst, '0.markup.type').to.equal('RenderMarkup');
1795+
expectPath(cst, '0.markup.renderArguments.0.type').to.equal('NamedArgument');
1796+
expectPath(cst, '0.markup.renderArguments.1.type').to.equal('VariableLookup');
1797+
});
17891798
});
17901799

17911800
function makeExpectPath(message: string) {

‎packages/liquid-html-parser/src/stage-1-cst.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ export interface ConcreteLiquidTagRenderMarkup
376376
snippet: ConcreteStringLiteral | ConcreteLiquidVariableLookup;
377377
alias: string | null;
378378
variable: ConcreteRenderVariableExpression | null;
379-
args: ConcreteLiquidNamedArgument[];
379+
renderArguments: ConcreteLiquidNamedArgument[];
380380
}
381381

382382
export interface ConcreteRenderVariableExpression
@@ -865,11 +865,29 @@ function toCST<T>(
865865
snippet: 0,
866866
variable: 1,
867867
alias: 2,
868-
args: 4,
868+
renderArguments: 3,
869869
locStart,
870870
locEnd,
871871
source,
872872
},
873+
renderArguments: 1,
874+
completionModeRenderArguments: function (
875+
_0,
876+
namedArguments,
877+
_2,
878+
_3,
879+
_4,
880+
_5,
881+
variableLookup,
882+
_7,
883+
) {
884+
const self = this as any;
885+
886+
// variableLookup.sourceString can be '' when there are no incomplete params
887+
return namedArguments
888+
.toAST(self.args.mapping)
889+
.concat(variableLookup.sourceString === '' ? [] : variableLookup.toAST(self.args.mapping));
890+
},
873891
snippetExpression: 0,
874892
renderVariableExpression: {
875893
type: ConcreteNodeTypes.RenderVariableExpression,

‎packages/liquid-html-parser/src/stage-2-ast.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,13 @@ export interface RenderMarkup extends ASTNode<NodeTypes.RenderMarkup> {
409409
alias: string | null;
410410
/** {% render 'snippet' [with variable] %} */
411411
variable: RenderVariableExpression | null;
412-
/** {% render 'snippet', arg1: value1, arg2: value2 %} */
412+
/**
413+
* WARNING: `args` could contain LiquidVariableLookup when we are in a completion context
414+
* because the NamedArgument isn't fully typed out.
415+
* E.g. {% render 'snippet', arg1: value1, arg2█ %}
416+
*
417+
* @example {% render 'snippet', arg1: value1, arg2: value2 %}
418+
*/
413419
args: LiquidNamedArgument[];
414420
}
415421

@@ -1803,7 +1809,15 @@ function toRenderMarkup(node: ConcreteLiquidTagRenderMarkup): RenderMarkup {
18031809
snippet: toExpression(node.snippet) as LiquidString | LiquidVariableLookup,
18041810
alias: node.alias,
18051811
variable: toRenderVariableExpression(node.variable),
1806-
args: node.args.map(toNamedArgument),
1812+
/**
1813+
* When we're in completion mode we won't necessarily have valid named
1814+
* arguments so we need to call toLiquidArgument instead of toNamedArgument.
1815+
* We cast using `as` so that this doesn't affect the type system used in
1816+
* other areas (like theme check) for a scenario that only occurs in
1817+
* completion mode. This means that our types are *wrong* in completion mode
1818+
* but this is the compromise we're making to get completions to work.
1819+
*/
1820+
args: node.renderArguments.map(toLiquidArgument) as LiquidNamedArgument[],
18071821
position: position(node),
18081822
source: node.source,
18091823
};

‎packages/theme-language-server-common/src/completions/CompletionsProvider.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { MetafieldDefinitionMap, SourceCodeType, ThemeDocset } from '@shopify/theme-check-common';
1+
import {
2+
GetSnippetDefinitionForURI,
3+
MetafieldDefinitionMap,
4+
SourceCodeType,
5+
ThemeDocset,
6+
} from '@shopify/theme-check-common';
27
import { CompletionItem, CompletionParams } from 'vscode-languageserver';
38
import { TypeSystem } from '../TypeSystem';
49
import { DocumentManager } from '../documents';
@@ -22,6 +27,7 @@ import {
2227
ContentForParameterCompletionProvider,
2328
} from './providers';
2429
import { GetSnippetNamesForURI } from './providers/RenderSnippetCompletionProvider';
30+
import { RenderSnippetParameterCompletionProvider } from './providers/RenderSnippetParameterCompletionProvider';
2531

2632
export interface CompletionProviderDependencies {
2733
documentManager: DocumentManager;
@@ -30,6 +36,7 @@ export interface CompletionProviderDependencies {
3036
getSnippetNamesForURI?: GetSnippetNamesForURI;
3137
getThemeSettingsSchemaForURI?: GetThemeSettingsSchemaForURI;
3238
getMetafieldDefinitions: (rootUri: string) => Promise<MetafieldDefinitionMap>;
39+
getSnippetDefinitionForURI?: GetSnippetDefinitionForURI;
3340
getThemeBlockNames?: (rootUri: string, includePrivate: boolean) => Promise<string[]>;
3441
log?: (message: string) => void;
3542
}
@@ -47,6 +54,9 @@ export class CompletionsProvider {
4754
getTranslationsForURI = async () => ({}),
4855
getSnippetNamesForURI = async () => [],
4956
getThemeSettingsSchemaForURI = async () => [],
57+
getSnippetDefinitionForURI = async (_uri, snippetName) => ({
58+
name: snippetName,
59+
}),
5060
getThemeBlockNames = async (_rootUri: string, _includePrivate: boolean) => [],
5161
log = () => {},
5262
}: CompletionProviderDependencies) {
@@ -72,6 +82,7 @@ export class CompletionsProvider {
7282
new FilterCompletionProvider(typeSystem),
7383
new TranslationCompletionProvider(documentManager, getTranslationsForURI),
7484
new RenderSnippetCompletionProvider(getSnippetNamesForURI),
85+
new RenderSnippetParameterCompletionProvider(getSnippetDefinitionForURI),
7586
new FilterNamedParameterCompletionProvider(themeDocset),
7687
];
7788
}

‎packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export class ObjectCompletionProvider implements Provider {
1111
if (!params.completionContext) return [];
1212

1313
const { partialAst, node, ancestors } = params.completionContext;
14+
const parentNode = ancestors.at(-1);
15+
1416
if (!node || node.type !== NodeTypes.VariableLookup) {
1517
return [];
1618
}
@@ -20,8 +22,11 @@ export class ObjectCompletionProvider implements Provider {
2022
return [];
2123
}
2224

23-
// ContentFor uses VariableLookup to support completion of NamedParams.
24-
if (ancestors.at(-1)?.type === NodeTypes.ContentForMarkup) {
25+
// ContentFor and Render uses VariableLookup to support completion of NamedParams.
26+
if (
27+
parentNode?.type === NodeTypes.ContentForMarkup ||
28+
parentNode?.type === NodeTypes.RenderMarkup
29+
) {
2530
return [];
2631
}
2732

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, beforeEach, it, expect } from 'vitest';
2+
import { CompletionsProvider } from '../CompletionsProvider';
3+
import { DocumentManager } from '../../documents';
4+
import { MetafieldDefinitionMap, SnippetDefinition } from '@shopify/theme-check-common';
5+
6+
describe('Module: RenderSnippetParameterCompletionProvider', async () => {
7+
let provider: CompletionsProvider;
8+
const mockSnippetName = 'product-card';
9+
const mockSnippetDefinition: SnippetDefinition = {
10+
name: mockSnippetName,
11+
liquidDoc: {
12+
parameters: [
13+
{
14+
name: 'title',
15+
description: 'The title of the product',
16+
type: 'string',
17+
required: true,
18+
nodeType: 'param',
19+
},
20+
{
21+
name: 'border-radius',
22+
description: 'The border radius in px',
23+
type: 'number',
24+
required: false,
25+
nodeType: 'param',
26+
},
27+
{
28+
name: 'no-type',
29+
description: 'This parameter has no type',
30+
type: null,
31+
required: true,
32+
nodeType: 'param',
33+
},
34+
{
35+
name: 'no-description',
36+
description: null,
37+
type: 'string',
38+
required: true,
39+
nodeType: 'param',
40+
},
41+
{
42+
name: 'no-type-or-description',
43+
description: null,
44+
type: null,
45+
required: true,
46+
nodeType: 'param',
47+
},
48+
],
49+
},
50+
};
51+
52+
beforeEach(async () => {
53+
provider = new CompletionsProvider({
54+
documentManager: new DocumentManager(),
55+
themeDocset: {
56+
filters: async () => [],
57+
objects: async () => [],
58+
tags: async () => [],
59+
systemTranslations: async () => ({}),
60+
},
61+
getMetafieldDefinitions: async (_rootUri: string) => ({} as MetafieldDefinitionMap),
62+
getSnippetDefinitionForURI: async (_uri, snippetName) => {
63+
if (mockSnippetName === snippetName) {
64+
return mockSnippetDefinition;
65+
}
66+
},
67+
});
68+
});
69+
70+
it("provide completion options that doesn't already exist in render tag", async () => {
71+
await expect(provider).to.complete(`{% render '${mockSnippetName}', █ %}`, [
72+
'title',
73+
'border-radius',
74+
'no-type',
75+
'no-description',
76+
'no-type-or-description',
77+
]);
78+
await expect(provider).to.complete(
79+
`{% render '${mockSnippetName}', title: 'foo', border-radius: 5, █ %}`,
80+
['no-type', 'no-description', 'no-type-or-description'],
81+
);
82+
});
83+
84+
it('does not provide completion options if the snippet does not exist', async () => {
85+
await expect(provider).to.complete(`{% render 'fake-snippet', █ %}`, []);
86+
});
87+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { NodeTypes } from '@shopify/liquid-html-parser';
2+
import {
3+
CompletionItem,
4+
CompletionItemKind,
5+
InsertTextFormat,
6+
MarkupKind,
7+
Range,
8+
TextEdit,
9+
} from 'vscode-languageserver';
10+
import { CURSOR, LiquidCompletionParams } from '../params';
11+
import { Provider } from './common';
12+
import { formatLiquidDocParameter } from '../../utils/liquidDoc';
13+
import { GetSnippetDefinitionForURI } from '@shopify/theme-check-common';
14+
15+
export type GetSnippetNamesForURI = (uri: string) => Promise<string[]>;
16+
17+
export class RenderSnippetParameterCompletionProvider implements Provider {
18+
constructor(private readonly getSnippetDefinitionForURI: GetSnippetDefinitionForURI) {}
19+
20+
async completions(params: LiquidCompletionParams): Promise<CompletionItem[]> {
21+
if (!params.completionContext) return [];
22+
23+
const { node, ancestors } = params.completionContext;
24+
const parentNode = ancestors.at(-1);
25+
26+
if (
27+
!node ||
28+
!parentNode ||
29+
node.type !== NodeTypes.VariableLookup ||
30+
parentNode.type !== NodeTypes.RenderMarkup ||
31+
parentNode.snippet.type !== 'String'
32+
) {
33+
return [];
34+
}
35+
36+
const userInputStr = node.name?.replace(CURSOR, '') || '';
37+
const snippetDefinition = await this.getSnippetDefinitionForURI(
38+
params.textDocument.uri,
39+
parentNode.snippet.value,
40+
);
41+
42+
const liquidDocParams = snippetDefinition?.liquidDoc?.parameters;
43+
44+
if (!liquidDocParams) {
45+
return [];
46+
}
47+
48+
let offset = node.name === CURSOR ? 1 : 0;
49+
50+
let start = params.document.textDocument.positionAt(node.position.start);
51+
let end = params.document.textDocument.positionAt(node.position.end - offset);
52+
53+
// We need to find out existing params in the render tag so we don't offer it again for completion
54+
const existingRenderParams = parentNode.args
55+
.filter((arg) => arg.type === NodeTypes.NamedArgument)
56+
.map((arg) => arg.name);
57+
58+
return liquidDocParams
59+
.filter((liquidDocParam) => !existingRenderParams.includes(liquidDocParam.name))
60+
.filter((liquidDocParam) => liquidDocParam.name.startsWith(userInputStr))
61+
.map((liquidDocParam) => ({
62+
label: liquidDocParam.name,
63+
kind: CompletionItemKind.Property,
64+
documentation: {
65+
kind: MarkupKind.Markdown,
66+
value: formatLiquidDocParameter(liquidDocParam, true),
67+
},
68+
textEdit: TextEdit.replace(Range.create(start, end), `${liquidDocParam.name}: $0`),
69+
insertTextFormat: InsertTextFormat.Snippet,
70+
}));
71+
}
72+
}

‎packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NodeTypes } from '@shopify/liquid-html-parser';
22
import { LiquidHtmlNode, SnippetDefinition, LiquidDocParameter } from '@shopify/theme-check-common';
33
import { Hover, HoverParams } from 'vscode-languageserver';
44
import { BaseHoverProvider } from '../BaseHoverProvider';
5+
import { formatLiquidDocParameter } from '../../utils/liquidDoc';
56

67
export class RenderSnippetHoverProvider implements BaseHoverProvider {
78
constructor(
@@ -70,13 +71,6 @@ export class RenderSnippetHoverProvider implements BaseHoverProvider {
7071
}
7172

7273
private buildParameters(parameters: LiquidDocParameter[]) {
73-
return parameters
74-
.map(({ name, type, description, required }: LiquidDocParameter) => {
75-
const nameStr = required ? `\`${name}\`` : `\`${name}\` (Optional)`;
76-
const typeStr = type ? `: ${type}` : '';
77-
const descStr = description ? ` - ${description}` : '';
78-
return `- ${nameStr}${typeStr}${descStr}`;
79-
})
80-
.join('\n');
74+
return parameters.map((param) => formatLiquidDocParameter(param)).join('\n');
8175
}
8276
}

‎packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.spec.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -81,28 +81,28 @@ describe('Module: RenderSnippetParameterHoverProvider', async () => {
8181
it('should return parameter info with type and description', async () => {
8282
await expect(provider).to.hover(
8383
`{% render 'product-card' ti█tle: 'My Product' %}`,
84-
'`title`: `string`\n- The title of the product',
84+
'### `title`: string\n\nThe title of the product',
8585
);
8686
});
8787

8888
it('should return parameter info with only type', async () => {
8989
await expect(provider).to.hover(
9090
`{% render 'product-card' no-descri█ption: 'value' %}`,
91-
'`no-description`: `string`',
91+
'### `no-description`: string',
9292
);
9393
});
9494

9595
it('should return parameter info with only description', async () => {
9696
await expect(provider).to.hover(
9797
`{% render 'product-card' no-ty█pe: 'value' %}`,
98-
'`no-type`\n- This parameter has no type',
98+
'### `no-type`\n\nThis parameter has no type',
9999
);
100100
});
101101

102102
it('should return only parameter name when no type or description', async () => {
103103
await expect(provider).to.hover(
104104
`{% render 'product-card' no-type-or-descri█ption: 'value' %}`,
105-
'`no-type-or-description`',
105+
'### `no-type-or-description`',
106106
);
107107
});
108108
});

‎packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.ts

+3-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { NodeTypes } from '@shopify/liquid-html-parser';
2-
import { LiquidHtmlNode, SnippetDefinition, LiquidDocParameter } from '@shopify/theme-check-common';
2+
import { LiquidHtmlNode, SnippetDefinition } from '@shopify/theme-check-common';
33
import { Hover, HoverParams } from 'vscode-languageserver';
44
import { BaseHoverProvider } from '../BaseHoverProvider';
5+
import { formatLiquidDocParameter } from '../../utils/liquidDoc';
56

67
export class RenderSnippetParameterHoverProvider implements BaseHoverProvider {
78
constructor(
@@ -45,21 +46,10 @@ export class RenderSnippetParameterHoverProvider implements BaseHoverProvider {
4546
return null;
4647
}
4748

48-
const parts = [];
49-
parts.push(
50-
hoveredParameter.type
51-
? `\`${hoveredParameter.name}\`: \`${hoveredParameter.type}\``
52-
: `\`${hoveredParameter.name}\``,
53-
);
54-
55-
if (hoveredParameter.description) {
56-
parts.push(`- ${hoveredParameter.description}`);
57-
}
58-
5949
return {
6050
contents: {
6151
kind: 'markdown',
62-
value: parts.join('\n'),
52+
value: formatLiquidDocParameter(hoveredParameter, true),
6353
},
6454
};
6555
}

‎packages/theme-language-server-common/src/server/startServer.ts

+1
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export function startServer(
258258
log,
259259
getThemeBlockNames,
260260
getMetafieldDefinitions,
261+
getSnippetDefinitionForURI,
261262
});
262263
const hoverProvider = new HoverProvider(
263264
documentManager,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { LiquidDocParameter } from '@shopify/theme-check-common';
2+
3+
export function formatLiquidDocParameter(
4+
{ name, type, description, required }: LiquidDocParameter,
5+
heading: boolean = false,
6+
) {
7+
const nameStr = required ? `\`${name}\`` : `\`${name}\` (Optional)`;
8+
const typeStr = type ? `: ${type}` : '';
9+
10+
if (heading) {
11+
const descStr = description ? `\n\n${description}` : '';
12+
return `### ${nameStr}${typeStr}${descStr}`;
13+
}
14+
15+
const descStr = description ? ` - ${description}` : '';
16+
return `- ${nameStr}${typeStr}${descStr}`;
17+
}

0 commit comments

Comments
 (0)
Please sign in to comment.