liquid-extractor.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
  2. import { generateNodeId } from './tree-sitter-helpers';
  3. /**
  4. * LiquidExtractor - Extracts relationships from Liquid template files
  5. *
  6. * Liquid is a templating language (used by Shopify, Jekyll, etc.) that doesn't
  7. * have traditional functions or classes. Instead, we extract:
  8. * - Section references ({% section 'name' %})
  9. * - Snippet references ({% render 'name' %} and {% include 'name' %})
  10. * - Schema blocks ({% schema %}...{% endschema %})
  11. */
  12. export class LiquidExtractor {
  13. private filePath: string;
  14. private source: string;
  15. private nodes: Node[] = [];
  16. private edges: Edge[] = [];
  17. private unresolvedReferences: UnresolvedReference[] = [];
  18. private errors: ExtractionError[] = [];
  19. constructor(filePath: string, source: string) {
  20. this.filePath = filePath;
  21. this.source = source;
  22. }
  23. /**
  24. * Extract from Liquid source
  25. */
  26. extract(): ExtractionResult {
  27. const startTime = Date.now();
  28. try {
  29. // Create file node
  30. const fileNode = this.createFileNode();
  31. // Shopify OS 2.0 JSON template / section group: link each section `type`
  32. // to its `sections/<type>.liquid` file. (No symbol nodes are emitted — the
  33. // JSON file just carries the references — so it stays out of any
  34. // symbol-bearing-file metric while its sections still get their dependents.)
  35. if (this.filePath.endsWith('.json')) {
  36. this.extractShopifyJsonSections(fileNode.id);
  37. } else {
  38. // Extract render/include statements (snippet references)
  39. this.extractSnippetReferences(fileNode.id);
  40. // Extract section references
  41. this.extractSectionReferences(fileNode.id);
  42. // Extract schema block
  43. this.extractSchema(fileNode.id);
  44. // Extract assign statements as variables
  45. this.extractAssignments(fileNode.id);
  46. }
  47. } catch (error) {
  48. this.errors.push({
  49. message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
  50. severity: 'error',
  51. code: 'parse_error',
  52. });
  53. }
  54. return {
  55. nodes: this.nodes,
  56. edges: this.edges,
  57. unresolvedReferences: this.unresolvedReferences,
  58. errors: this.errors,
  59. durationMs: Date.now() - startTime,
  60. };
  61. }
  62. /**
  63. * Create a file node for the Liquid template
  64. */
  65. private createFileNode(): Node {
  66. const lines = this.source.split('\n');
  67. const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
  68. const fileNode: Node = {
  69. id,
  70. kind: 'file',
  71. name: this.filePath.split('/').pop() || this.filePath,
  72. qualifiedName: this.filePath,
  73. filePath: this.filePath,
  74. language: 'liquid',
  75. startLine: 1,
  76. endLine: lines.length,
  77. startColumn: 0,
  78. endColumn: lines[lines.length - 1]?.length || 0,
  79. updatedAt: Date.now(),
  80. };
  81. this.nodes.push(fileNode);
  82. return fileNode;
  83. }
  84. /**
  85. * Shopify OS 2.0 JSON template / section group. Both have a `sections` object
  86. * mapping an id → `{ "type": "<section-name>", ... }`; the `type` names a
  87. * `sections/<type>.liquid` file. Emit a `references` edge to each, so a section
  88. * used only from a JSON template (the OS 2.0 norm) is no longer orphaned.
  89. */
  90. private extractShopifyJsonSections(fromNodeId: string): void {
  91. let parsed: unknown;
  92. try {
  93. parsed = JSON.parse(this.source);
  94. } catch {
  95. return; // not valid JSON (or a partial) — nothing to link
  96. }
  97. const sections = (parsed as { sections?: Record<string, { type?: unknown }> })?.sections;
  98. if (!sections || typeof sections !== 'object') return;
  99. const seen = new Set<string>();
  100. for (const key of Object.keys(sections)) {
  101. const type = sections[key]?.type;
  102. if (typeof type !== 'string' || seen.has(type)) continue;
  103. seen.add(type);
  104. this.unresolvedReferences.push({
  105. fromNodeId,
  106. referenceName: `sections/${type}.liquid`,
  107. referenceKind: 'references',
  108. line: 1,
  109. column: 0,
  110. });
  111. }
  112. }
  113. /**
  114. * Extract {% render 'snippet' %} and {% include 'snippet' %} references
  115. */
  116. private extractSnippetReferences(fileNodeId: string): void {
  117. // Match {% render 'name' %} or {% include 'name' %} with optional parameters
  118. const renderRegex = /\{%[-]?\s*(render|include)\s+['"]([^'"]+)['"]/g;
  119. let match;
  120. while ((match = renderRegex.exec(this.source)) !== null) {
  121. const [fullMatch, tagType, snippetName] = match;
  122. const line = this.getLineNumber(match.index);
  123. // Create an import node for searchability
  124. const importNodeId = generateNodeId(this.filePath, 'import', snippetName!, line);
  125. const importNode: Node = {
  126. id: importNodeId,
  127. kind: 'import',
  128. name: snippetName!,
  129. qualifiedName: `${this.filePath}::import:${snippetName}`,
  130. filePath: this.filePath,
  131. language: 'liquid',
  132. signature: fullMatch,
  133. startLine: line,
  134. endLine: line,
  135. startColumn: match.index - this.getLineStart(line),
  136. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  137. updatedAt: Date.now(),
  138. };
  139. this.nodes.push(importNode);
  140. // Add containment edge from file to import
  141. this.edges.push({
  142. source: fileNodeId,
  143. target: importNodeId,
  144. kind: 'contains',
  145. });
  146. // Create a component node for the snippet reference
  147. const nodeId = generateNodeId(this.filePath, 'component', `${tagType}:${snippetName}`, line);
  148. const node: Node = {
  149. id: nodeId,
  150. kind: 'component',
  151. name: snippetName!,
  152. qualifiedName: `${this.filePath}::${tagType}:${snippetName}`,
  153. filePath: this.filePath,
  154. language: 'liquid',
  155. startLine: line,
  156. endLine: line,
  157. startColumn: match.index - this.getLineStart(line),
  158. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  159. updatedAt: Date.now(),
  160. };
  161. this.nodes.push(node);
  162. // Add containment edge from file
  163. this.edges.push({
  164. source: fileNodeId,
  165. target: nodeId,
  166. kind: 'contains',
  167. });
  168. // Add unresolved reference to the snippet file
  169. this.unresolvedReferences.push({
  170. fromNodeId: fileNodeId,
  171. referenceName: `snippets/${snippetName}.liquid`,
  172. referenceKind: 'references',
  173. line,
  174. column: match.index - this.getLineStart(line),
  175. });
  176. }
  177. }
  178. /**
  179. * Extract {% section 'name' %} references
  180. */
  181. private extractSectionReferences(fileNodeId: string): void {
  182. // Match {% section 'name' %}
  183. const sectionRegex = /\{%[-]?\s*section\s+['"]([^'"]+)['"]/g;
  184. let match;
  185. while ((match = sectionRegex.exec(this.source)) !== null) {
  186. const [fullMatch, sectionName] = match;
  187. const line = this.getLineNumber(match.index);
  188. // Create an import node for searchability
  189. const importNodeId = generateNodeId(this.filePath, 'import', sectionName!, line);
  190. const importNode: Node = {
  191. id: importNodeId,
  192. kind: 'import',
  193. name: sectionName!,
  194. qualifiedName: `${this.filePath}::import:${sectionName}`,
  195. filePath: this.filePath,
  196. language: 'liquid',
  197. signature: fullMatch,
  198. startLine: line,
  199. endLine: line,
  200. startColumn: match.index - this.getLineStart(line),
  201. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  202. updatedAt: Date.now(),
  203. };
  204. this.nodes.push(importNode);
  205. // Add containment edge from file to import
  206. this.edges.push({
  207. source: fileNodeId,
  208. target: importNodeId,
  209. kind: 'contains',
  210. });
  211. // Create a component node for the section reference
  212. const nodeId = generateNodeId(this.filePath, 'component', `section:${sectionName}`, line);
  213. const node: Node = {
  214. id: nodeId,
  215. kind: 'component',
  216. name: sectionName!,
  217. qualifiedName: `${this.filePath}::section:${sectionName}`,
  218. filePath: this.filePath,
  219. language: 'liquid',
  220. startLine: line,
  221. endLine: line,
  222. startColumn: match.index - this.getLineStart(line),
  223. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  224. updatedAt: Date.now(),
  225. };
  226. this.nodes.push(node);
  227. // Add containment edge from file
  228. this.edges.push({
  229. source: fileNodeId,
  230. target: nodeId,
  231. kind: 'contains',
  232. });
  233. // Add unresolved reference to the section file
  234. this.unresolvedReferences.push({
  235. fromNodeId: fileNodeId,
  236. referenceName: `sections/${sectionName}.liquid`,
  237. referenceKind: 'references',
  238. line,
  239. column: match.index - this.getLineStart(line),
  240. });
  241. }
  242. }
  243. /**
  244. * Extract {% schema %}...{% endschema %} blocks
  245. */
  246. private extractSchema(fileNodeId: string): void {
  247. // Match {% schema %}...{% endschema %}
  248. const schemaRegex = /\{%[-]?\s*schema\s*[-]?%\}([\s\S]*?)\{%[-]?\s*endschema\s*[-]?%\}/g;
  249. let match;
  250. while ((match = schemaRegex.exec(this.source)) !== null) {
  251. const [fullMatch, schemaContent] = match;
  252. const startLine = this.getLineNumber(match.index);
  253. const endLine = this.getLineNumber(match.index + fullMatch.length);
  254. // Try to parse the schema JSON to get the name
  255. let schemaName = 'schema';
  256. try {
  257. const schemaJson = JSON.parse(schemaContent!);
  258. if (schemaJson.name) {
  259. // Shopify schema names can be translation objects like {"en": "...", "fr": "..."}
  260. schemaName = typeof schemaJson.name === 'string'
  261. ? schemaJson.name
  262. : schemaJson.name.en || Object.values(schemaJson.name)[0] as string || 'schema';
  263. }
  264. } catch {
  265. // Schema isn't valid JSON, use default name
  266. }
  267. // Create a node for the schema
  268. const nodeId = generateNodeId(this.filePath, 'constant', `schema:${schemaName}`, startLine);
  269. const node: Node = {
  270. id: nodeId,
  271. kind: 'constant',
  272. name: schemaName,
  273. qualifiedName: `${this.filePath}::schema:${schemaName}`,
  274. filePath: this.filePath,
  275. language: 'liquid',
  276. startLine,
  277. endLine,
  278. startColumn: match.index - this.getLineStart(startLine),
  279. endColumn: 0,
  280. // SECURITY (#383): don't dump the raw {% schema %} JSON (section settings
  281. // + default values) into the docstring — the schema name is already in
  282. // `name`, so the data block adds nothing but a potential leak of any
  283. // IDs/endpoints/keys a developer placed in setting defaults.
  284. updatedAt: Date.now(),
  285. };
  286. this.nodes.push(node);
  287. // Add containment edge from file
  288. this.edges.push({
  289. source: fileNodeId,
  290. target: nodeId,
  291. kind: 'contains',
  292. });
  293. }
  294. }
  295. /**
  296. * Extract {% assign var = value %} statements
  297. */
  298. private extractAssignments(fileNodeId: string): void {
  299. // Match {% assign variable_name = ... %}
  300. const assignRegex = /\{%[-]?\s*assign\s+(\w+)\s*=/g;
  301. let match;
  302. while ((match = assignRegex.exec(this.source)) !== null) {
  303. const [, variableName] = match;
  304. const line = this.getLineNumber(match.index);
  305. // Create a variable node
  306. const nodeId = generateNodeId(this.filePath, 'variable', variableName!, line);
  307. const node: Node = {
  308. id: nodeId,
  309. kind: 'variable',
  310. name: variableName!,
  311. qualifiedName: `${this.filePath}::${variableName}`,
  312. filePath: this.filePath,
  313. language: 'liquid',
  314. startLine: line,
  315. endLine: line,
  316. startColumn: match.index - this.getLineStart(line),
  317. endColumn: match.index - this.getLineStart(line) + match[0].length,
  318. updatedAt: Date.now(),
  319. };
  320. this.nodes.push(node);
  321. // Add containment edge from file
  322. this.edges.push({
  323. source: fileNodeId,
  324. target: nodeId,
  325. kind: 'contains',
  326. });
  327. }
  328. }
  329. /**
  330. * Get the line number for a character index
  331. */
  332. private getLineNumber(index: number): number {
  333. const substring = this.source.substring(0, index);
  334. return (substring.match(/\n/g) || []).length + 1;
  335. }
  336. /**
  337. * Get the character index of the start of a line
  338. */
  339. private getLineStart(lineNumber: number): number {
  340. const lines = this.source.split('\n');
  341. let index = 0;
  342. for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
  343. index += lines[i]!.length + 1; // +1 for newline
  344. }
  345. return index;
  346. }
  347. }