diff --git a/packages/theme-check-common/package.json b/packages/theme-check-common/package.json index 002dc550c..24914f44d 100644 --- a/packages/theme-check-common/package.json +++ b/packages/theme-check-common/package.json @@ -32,11 +32,15 @@ "line-column": "^1.0.2", "lodash": "^4.17.23", "minimatch": "^10.2.1", + "postcss": "^8.4.49", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.0.0", "vscode-json-languageservice": "^5.3.10", "vscode-uri": "^3.0.7" }, "devDependencies": { "@types/line-column": "^1.0.0", - "@types/lodash": "^4.17.20" + "@types/lodash": "^4.17.20", + "@types/postcss-safe-parser": "^5.0.4" } } diff --git a/packages/theme-check-common/src/checks/css-class-within-stylesheet/index.ts b/packages/theme-check-common/src/checks/css-class-within-stylesheet/index.ts new file mode 100644 index 000000000..504b5919d --- /dev/null +++ b/packages/theme-check-common/src/checks/css-class-within-stylesheet/index.ts @@ -0,0 +1,184 @@ +import { AttrDoubleQuoted, AttrSingleQuoted, HtmlElement, HtmlVoidElement, HtmlSelfClosingElement, NodeTypes, TextNode } from '@shopify/liquid-html-parser'; +import { Severity, SourceCodeType, LiquidCheckDefinition, Reference } from '../../types'; + +export const CSSClassWithinStylesheet: LiquidCheckDefinition = { + meta: { + code: 'CSSClassWithinStylesheet', + name: 'Prevent CSS class selectors defined in other files', + docs: { + description: 'This check detects the use of CSS class selectors defined in other liquid files\' stylesheet tags.', + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + const htmlNodes: (HtmlElement | HtmlVoidElement | HtmlSelfClosingElement)[] = []; + + const getDirectAncestorsForCurrentFile = async (): Promise> => { + if (!context.getReferences) { + return new Set(); + } + return getDirectAncestors(context.file.uri, context.getReferences, context.toRelativePath); + }; + + return { + async HtmlElement(node) { + htmlNodes.push(node); + }, + async HtmlVoidElement(node) { + htmlNodes.push(node); + }, + async HtmlSelfClosingElement(node) { + htmlNodes.push(node); + }, + async onCodePathEnd() { + const directAncestors = await getDirectAncestorsForCurrentFile(); + + for (const node of htmlNodes) { + const classAttr = node.attributes + .filter((attr) => attr.type === NodeTypes.AttrSingleQuoted || attr.type === NodeTypes.AttrDoubleQuoted) + .find((attr) => { + if (attr.type !== NodeTypes.AttrSingleQuoted && attr.type !== NodeTypes.AttrDoubleQuoted) { + return false; + } + + const attrName = attr.name[0]; + + return attrName.type === NodeTypes.TextNode && attrName.value === 'class' + }) as AttrSingleQuoted | AttrDoubleQuoted | undefined; + + if (!classAttr) continue; + + const stylesheetTagSelectors = await context.getStylesheetTagSelectors?.(); + const assetStylesheetSelectors = await context.getAssetStylesheetSelectors?.(); + if (!stylesheetTagSelectors && !assetStylesheetSelectors) continue; + + const classAttrValues = classAttr.value.filter((node) => node.type === NodeTypes.TextNode) as TextNode[]; + + for (const classAttrValue of classAttrValues) { + const classRegex = /\S+/g; + let match; + + while ((match = classRegex.exec(classAttrValue.value)) !== null) { + const className = match[0]; + const classStartOffset = match.index; + + const foundInOtherFiles: string[] = []; + + // Helper to check if class exists in a stylesheet's selectors + const hasClass = (selectors: { type: string; handle: string }[] | undefined) => + selectors?.some((s) => s.type === 'class' && s.handle === className) || false; + + // 1. Check local stylesheet tag (current file) + let foundInLocalFile = false; + if (stylesheetTagSelectors) { + for (const [relativePath, stylesheet] of stylesheetTagSelectors) { + if (context.file.uri.endsWith(relativePath) && hasClass(stylesheet.selectors)) { + foundInLocalFile = true; + break; + } + } + } + if (foundInLocalFile) continue; + + // 2. Check ancestor stylesheet tags + let foundInAncestor = false; + if (stylesheetTagSelectors) { + for (const [relativePath, stylesheet] of stylesheetTagSelectors) { + if (directAncestors.has(relativePath) && hasClass(stylesheet.selectors)) { + foundInAncestor = true; + break; + } + } + } + if (foundInAncestor) continue; + + // 3. Check asset stylesheet content + let foundInAsset = false; + if (assetStylesheetSelectors) { + for (const [, stylesheet] of assetStylesheetSelectors) { + if (hasClass(stylesheet.selectors)) { + foundInAsset = true; + break; + } + } + } + if (foundInAsset) continue; + + // 4. Check other liquid files and report error if found + if (stylesheetTagSelectors) { + for (const [relativePath, stylesheet] of stylesheetTagSelectors) { + // Skip current file and ancestors (already checked) + if (context.file.uri.endsWith(relativePath) || directAncestors.has(relativePath)) { + continue; + } + if (hasClass(stylesheet.selectors)) { + foundInOtherFiles.push(relativePath); + } + } + } + + if (foundInOtherFiles.length > 0) { + const filesMessage = foundInOtherFiles.map((f) => `\`${f}\``).join(', '); + context.report({ + message: `CSS class \`${className}\` is defined in another liquid file's stylesheet tags that isn't an explicit ancestor: ${filesMessage}`, + startIndex: classAttrValue.position.start + classStartOffset, + endIndex: classAttrValue.position.start + classStartOffset + className.length, + }); + } + } + } + } + } + }; + }, +}; + + +/** + * Recursively find all direct ancestors (parents, grandparents, etc.) for a given file. + * A direct ancestor is a file that has a 'direct' reference to the current file or its descendants. + * + * @param uri - The URI of the file to find ancestors for + * @param getReferences - Function to get references for a given URI + * @param toRelativePath - Function to convert a URI to a relative path + * @param visited - Set of already visited URIs to prevent infinite loops + * @returns Set of relative paths of all direct ancestors + */ +async function getDirectAncestors( + uri: string, + getReferences: (uri: string) => Promise, + toRelativePath: (uri: string) => string, + visited: Set = new Set(), +): Promise> { + const ancestors = new Set(); + + // Prevent infinite loops from circular references + if (visited.has(uri)) { + return ancestors; + } + visited.add(uri); + + const references = await getReferences(uri); + + // Filter for direct references only + const directParents = references + .filter((ref) => ref.type === 'direct') + .map((ref) => ref.source.uri); + + for (const parentUri of directParents) { + ancestors.add(toRelativePath(parentUri)); + + // Recursively get ancestors of the parent + const grandAncestors = await getDirectAncestors(parentUri, getReferences, toRelativePath, visited); + for (const ancestor of grandAncestors) { + ancestors.add(ancestor); + } + } + + return ancestors; +} diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index 7ca5119fa..254e98b00 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -9,6 +9,7 @@ import { AssetSizeJavaScript } from './asset-size-javascript'; import { BlockIdUsage } from './block-id-usage'; import { CdnPreconnect } from './cdn-preconnect'; import { ContentForHeaderModification } from './content-for-header-modification'; +import { CSSClassWithinStylesheet } from './css-class-within-stylesheet'; import { DeprecateBgsizes } from './deprecate-bgsizes'; import { DeprecateLazysizes } from './deprecate-lazysizes'; import { DeprecatedFilter } from './deprecated-filter'; @@ -77,6 +78,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ BlockIdUsage, CdnPreconnect, ContentForHeaderModification, + CSSClassWithinStylesheet, DeprecateBgsizes, DeprecateLazysizes, DeprecatedFilter, diff --git a/packages/theme-check-common/src/index.ts b/packages/theme-check-common/src/index.ts index df10c2c1a..eb93b79b6 100644 --- a/packages/theme-check-common/src/index.ts +++ b/packages/theme-check-common/src/index.ts @@ -60,6 +60,7 @@ export * from './visitor'; export * from './liquid-doc/liquidDoc'; export { getBlockName } from './liquid-doc/arguments'; export * from './liquid-doc/utils'; +export * from './stylesheet/stylesheetSelectors'; const defaultErrorHandler = (_error: Error): void => { // Silently ignores errors by default. diff --git a/packages/theme-check-common/src/stylesheet/stylesheetSelectors.ts b/packages/theme-check-common/src/stylesheet/stylesheetSelectors.ts new file mode 100644 index 000000000..4cc03aff9 --- /dev/null +++ b/packages/theme-check-common/src/stylesheet/stylesheetSelectors.ts @@ -0,0 +1,185 @@ +import postcss from 'postcss'; +import safeParser from 'postcss-safe-parser'; +import selectorParser from 'postcss-selector-parser'; +import { SourceCodeType, UriString, LiquidHtmlNode } from '../types'; +import { visit } from '../visitor'; + +export type GetStylesheetForURI = (uri: UriString) => Promise; + +export type Stylesheet = { + uri: UriString; + selectors?: CSSSelector[]; +}; + +export type CSSSelector = { + handle: string; + type: CSSSelectorType; +}; + +export type CSSSelectorType = + | 'class' + | 'id' + | 'tag' + | 'attribute' + | 'pseudo' + | 'universal' + | 'nesting' + | 'combinator' + | 'comment' + | 'root' + | 'selector' + | 'string'; + +/** + * Check if a Liquid AST contains a stylesheet tag. + */ +export function hasStylesheetTag(ast: LiquidHtmlNode): boolean { + let foundStylesheetTag = false; + visit(ast, { + LiquidRawTag(node) { + if (node.name === 'stylesheet') foundStylesheetTag = true; + }, + }); + return foundStylesheetTag; +} + +/** + * Extract all CSS selectors from {% stylesheet %} tags in a Liquid file. + * + * @example + * ```liquid + * {% stylesheet %} + * .my_class { color: red; } + * .my_class .my_other_class { color: blue; } + * div.my_new_class { color: green; } + * {% endstylesheet %} + * ``` + * + * Returns: + * - my_class (class) + * - my_other_class (class) + * - div (tag) + * - my_new_class (class) + */ +export function extractStylesheetSelectors(uri: UriString, ast: LiquidHtmlNode): Stylesheet { + const cssContents: string[] = []; + + // Find all stylesheet tags and extract their CSS content + visit(ast, { + LiquidRawTag(node) { + if (node.name === 'stylesheet') { + // Extract the raw CSS content from the stylesheet tag + const cssContent = node.body.value; + if (cssContent && typeof cssContent === 'string') { + cssContents.push(cssContent); + } + } + }, + }); + + if (cssContents.length === 0) { + return { uri }; + } + + // Parse all CSS content and extract selectors + const selectorsMap = new Map(); + + for (const cssContent of cssContents) { + extractSelectorsFromCSSContent(cssContent, selectorsMap); + } + + const selectors = Array.from(selectorsMap.values()); + + if (selectors.length === 0) { + return { uri }; + } + + return { + uri, + selectors, + }; +} + +/** + * Extract all CSS selectors from a CSS file content (e.g., .css files in assets folder). + * + * @param uri - The URI of the CSS file + * @param cssContent - The CSS content to parse + * @returns Stylesheet with extracted selectors + */ +export function extractStylesheetFromCSS(uri: UriString, cssContent: string): Stylesheet { + const selectorsMap = new Map(); + extractSelectorsFromCSSContent(cssContent, selectorsMap); + + const selectors = Array.from(selectorsMap.values()); + + if (selectors.length === 0) { + return { uri }; + } + + return { + uri, + selectors, + }; +} + +/** + * Parse CSS content and extract all selectors into the provided map. + * Uses a map to deduplicate selectors by their handle. + */ +function extractSelectorsFromCSSContent(cssContent: string, selectorsMap: Map): void { + try { + const root = postcss().process(cssContent, { + parser: safeParser, + from: undefined, + }).root; + + root.walkRules((rule) => { + // Skip @keyframes rules + const atRule = rule.parent; + if (atRule && 'name' in atRule && String(atRule.name).includes('keyframes')) { + return; + } + + try { + selectorParser((selectors) => { + selectors.walk((node) => { + const selector = nodeToSelector(node); + if (selector) { + // Use handle + type as key to avoid duplicates + const key = `${selector.type}:${selector.handle}`; + if (!selectorsMap.has(key)) { + selectorsMap.set(key, selector); + } + } + }); + }).processSync(rule.selector); + } catch { + // Ignore selector parsing errors + } + }); + } catch { + // Ignore CSS parsing errors + } +} + +/** + * Convert a postcss-selector-parser node to a CSSSelector. + * Returns undefined for nodes that shouldn't be included (combinators, etc.) + */ +function nodeToSelector(node: selectorParser.Node): CSSSelector | undefined { + switch (node.type) { + case 'class': + case 'id': + case 'tag': + case 'pseudo': + return { handle: node.value, type: node.type }; + case 'attribute': + return { handle: node.attribute, type: 'attribute' }; + case 'universal': + return { handle: '*', type: 'universal' }; + // Skip combinators, comments, roots, selectors, strings, nesting + default: + return undefined; + } +} diff --git a/packages/theme-check-common/src/test/test-helper.ts b/packages/theme-check-common/src/test/test-helper.ts index 2eb5feaaf..336094bbc 100644 --- a/packages/theme-check-common/src/test/test-helper.ts +++ b/packages/theme-check-common/src/test/test-helper.ts @@ -9,6 +9,8 @@ import { createCorrector, Dependencies, extractDocDefinition, + extractStylesheetFromCSS, + extractStylesheetSelectors, FixApplicator, isBlock, isSection, @@ -19,6 +21,7 @@ import { recommended, SectionSchema, SourceCodeType, + Stylesheet, StringCorrector, Theme, ThemeBlockSchema, @@ -97,6 +100,28 @@ export async function check( } return extractDocDefinition(file.uri, file.ast); }, + async getStylesheetTagSelectors() { + const result = new Map(); + for (const file of theme) { + if (!isLiquidHtmlNode(file.ast)) continue; + const relativePath = path.relative(file.uri, rootUri); + const selectors = extractStylesheetSelectors(file.uri, file.ast); + result.set(relativePath, selectors); + } + return result; + }, + async getAssetStylesheetSelectors() { + const result = new Map(); + // Find all .css files in assets folder + for (const [relativePath, content] of Object.entries(themeDesc)) { + if (relativePath.startsWith('assets/') && relativePath.endsWith('.css')) { + const uri = path.join(rootUri, relativePath); + const stylesheet = extractStylesheetFromCSS(uri, content); + result.set(relativePath, stylesheet); + } + } + return result; + }, themeDocset: { async filters() { return [ diff --git a/packages/theme-check-common/src/types.ts b/packages/theme-check-common/src/types.ts index c5053c5a4..3e3f1e015 100644 --- a/packages/theme-check-common/src/types.ts +++ b/packages/theme-check-common/src/types.ts @@ -18,6 +18,7 @@ import { import { JsonValidationSet, ThemeDocset } from './types/theme-liquid-docs'; import { AppBlockSchema, SectionSchema, ThemeBlockSchema } from './types/theme-schemas'; import { DocDefinition } from './liquid-doc/liquidDoc'; +import { Stylesheet } from './stylesheet/stylesheetSelectors'; export * from './jsonc/types'; export * from './types/schema-prop-factory'; @@ -392,6 +393,24 @@ export interface Dependencies { * Returns an empty array if no files reference this file */ getReferences?: (uri: string) => Promise; + + /** + * Asynchronously get all CSS selectors defined in {% stylesheet %} tags across all liquid files. + * Returns a Map where the key is the relative URI of the file and the value is the Stylesheet. + * May return undefined when the theme isn't preloaded. + * + * Used in theme-checks for cross-file checks. + */ + getStylesheetTagSelectors?: () => Promise>; + + /** + * Asynchronously get all CSS selectors defined in .css files under the assets folder. + * Returns a Map where the key is the relative path to the CSS file and the value is the Stylesheet. + * May return undefined when the theme isn't preloaded. + * + * Used in theme-checks for cross-file checks. + */ + getAssetStylesheetSelectors?: () => Promise>; } export type ValidateJSON = ( diff --git a/packages/theme-check-node/configs/all.yml b/packages/theme-check-node/configs/all.yml index b74b3741e..d9db38583 100644 --- a/packages/theme-check-node/configs/all.yml +++ b/packages/theme-check-node/configs/all.yml @@ -31,6 +31,9 @@ AssetSizeJavaScript: BlockIdUsage: enabled: true severity: 1 +CSSClassWithinStylesheet: + enabled: true + severity: 1 CdnPreconnect: enabled: true severity: 0 diff --git a/packages/theme-check-node/configs/recommended.yml b/packages/theme-check-node/configs/recommended.yml index 0ebc31660..d187cdad3 100644 --- a/packages/theme-check-node/configs/recommended.yml +++ b/packages/theme-check-node/configs/recommended.yml @@ -9,6 +9,9 @@ AssetPreload: BlockIdUsage: enabled: true severity: 1 +CSSClassWithinStylesheet: + enabled: true + severity: 1 CdnPreconnect: enabled: true severity: 0 diff --git a/packages/theme-check-node/src/index.ts b/packages/theme-check-node/src/index.ts index a21f825ce..65f3e81cc 100644 --- a/packages/theme-check-node/src/index.ts +++ b/packages/theme-check-node/src/index.ts @@ -3,21 +3,29 @@ import { DocDefinition, JSONSourceCode, JSONValidator, + LiquidHtmlNodeTypes, LiquidSourceCode, Offense, + Reference, SectionSchema, + SourceCodeType, + Stylesheet, Theme, ThemeBlockSchema, toSourceCode as commonToSourceCode, check as coreCheck, extractDocDefinition, + extractStylesheetFromCSS, + extractStylesheetSelectors, filePathSupportsLiquidDoc, isBlock, isIgnored, isSection, + parseJSON, memo, path as pathUtils, toSchema, + visit, } from '@shopify/theme-check-common'; import { ThemeLiquidDocsManager } from '@shopify/theme-check-docs-updater'; import { isLiquidHtmlNode } from '@shopify/liquid-html-parser'; @@ -134,6 +142,100 @@ export async function themeCheckRun( }), ]), ); + const stylesheetTagSelectors = new Map( + theme.map((file) => [ + path.relative(URI.file(root).toString(), file.uri), + memo(async (): Promise => { + const ast = file.ast; + if (!isLiquidHtmlNode(ast)) { + return undefined; + } + return extractStylesheetSelectors(file.uri, ast); + }), + ]), + ); + + // Get all CSS files in assets folder + const assetsPath = path.join(root, 'assets'); + let cssFilePaths: string[] = []; + try { + cssFilePaths = await asyncGlob('**/*.css', { cwd: assetsPath }); + } catch { + // Ignore errors (e.g., assets folder doesn't exist) + } + + const assetStylesheetSelectors = new Map( + cssFilePaths.map((cssFile) => [ + `assets/${cssFile}`, + memo(async (): Promise => { + const absolutePath = path.join(assetsPath, cssFile); + try { + const cssContent = await fs.readFile(absolutePath, 'utf-8'); + const uri = URI.file(absolutePath).toString(); + return extractStylesheetFromCSS(uri, cssContent); + } catch { + return undefined; + } + }), + ]), + ); + + // Build a reverse reference map so getReferences works in CLI mode. + // Covers {% render 'snippet' %}, {% section 'name' %}, and + // {% content_for 'block', type: '' %} (direct references). + const referencesByTarget = new Map(); + const rootUri = URI.file(root).toString(); + for (const file of theme) { + const ast = file.ast; + if (!isLiquidHtmlNode(ast)) continue; + visit(ast, { + RenderMarkup(node) { + const snippet = node.snippet; + if (typeof snippet !== 'string' && snippet.type === LiquidHtmlNodeTypes.String) { + const snippetUri = pathUtils.join(rootUri, 'snippets', `${snippet.value}.liquid`); + const refs = referencesByTarget.get(snippetUri) ?? []; + refs.push({ type: 'direct', source: { uri: file.uri }, target: { uri: snippetUri } }); + referencesByTarget.set(snippetUri, refs); + } + }, + LiquidTag(node) { + const markup = node.markup; + if (typeof markup === 'string' || Array.isArray(markup)) return; + + if (node.name === 'section') { + if ((markup as any).type !== LiquidHtmlNodeTypes.String) return; + const sectionName = (markup as any).value as string; + const sectionUri = pathUtils.join(rootUri, 'sections', `${sectionName}.liquid`); + const refs = referencesByTarget.get(sectionUri) ?? []; + refs.push({ type: 'direct', source: { uri: file.uri }, target: { uri: sectionUri } }); + referencesByTarget.set(sectionUri, refs); + } else if (node.name === 'content_for') { + if ((markup as any).contentForType?.value !== 'block') return; + const blockTypeArg = (markup as any).args?.find((arg: any) => arg.name === 'type'); + if (!blockTypeArg || blockTypeArg.value.type !== LiquidHtmlNodeTypes.String) return; + const blockName = blockTypeArg.value.value as string; + const blockUri = pathUtils.join(rootUri, 'blocks', `${blockName}.liquid`); + const refs = referencesByTarget.get(blockUri) ?? []; + refs.push({ type: 'direct', source: { uri: file.uri }, target: { uri: blockUri } }); + referencesByTarget.set(blockUri, refs); + } + }, + LiquidRawTag(node) { + if (node.name !== 'schema') return; + if (!isSection(file.uri) && !isBlock(file.uri)) return; + const parsed = parseJSON(node.body.value, undefined, false); + if (parsed instanceof Error || !Array.isArray(parsed?.blocks)) return; + for (const block of parsed.blocks) { + const blockType = block?.type; + if (typeof blockType !== 'string' || !blockType.startsWith('_')) continue; + const blockUri = pathUtils.join(rootUri, 'blocks', `${blockType}.liquid`); + const refs = referencesByTarget.get(blockUri) ?? []; + refs.push({ type: 'direct', source: { uri: file.uri }, target: { uri: blockUri } }); + referencesByTarget.set(blockUri, refs); + } + }, + }); + } const offenses = await coreCheck(theme, config, { fs: NodeFileSystem, @@ -147,6 +249,27 @@ export async function themeCheckRun( getBlockSchema: async (name) => blockSchemas.get(name)?.(), getAppBlockSchema: async (name) => blockSchemas.get(name)?.() as any, // cheating... but TODO getDocDefinition: async (relativePath) => docDefinitions.get(relativePath)?.(), + getReferences: async (uri: string) => referencesByTarget.get(uri) ?? [], + getStylesheetTagSelectors: async () => { + const result = new Map(); + for (const [relativePath, getSelectors] of stylesheetTagSelectors) { + const selectors = await getSelectors(); + if (selectors) { + result.set(relativePath, selectors); + } + } + return result; + }, + getAssetStylesheetSelectors: async () => { + const result = new Map(); + for (const [relativePath, getSelectors] of assetStylesheetSelectors) { + const selectors = await getSelectors(); + if (selectors) { + result.set(relativePath, selectors); + } + } + return result; + }, }); return { diff --git a/packages/theme-language-server-common/src/diagnostics/runChecks.ts b/packages/theme-language-server-common/src/diagnostics/runChecks.ts index e421c6db3..ddcc98509 100644 --- a/packages/theme-language-server-common/src/diagnostics/runChecks.ts +++ b/packages/theme-language-server-common/src/diagnostics/runChecks.ts @@ -1,13 +1,16 @@ import { check, + extractStylesheetFromCSS, findRoot, makeFileExists, Offense, path, + recursiveReadDirectory, Reference, SectionSchema, Severity, SourceCodeType, + Stylesheet, ThemeBlockSchema, } from '@shopify/theme-check-common'; @@ -97,6 +100,41 @@ export function makeRunChecks( if (doc?.type !== SourceCodeType.LiquidHtml) return undefined; return doc.getLiquidDoc(); }, + + async getStylesheetTagSelectors() { + const result = new Map(); + await documentManager.preload(config.rootUri); + + for (const sourceCode of documentManager.theme(config.rootUri, true)) { + if (sourceCode.type !== SourceCodeType.LiquidHtml) continue; + const relativePath = path.relative(sourceCode.uri, config.rootUri); + const selectors = await sourceCode.getStylesheetSelectors(); + if (selectors) { + result.set(relativePath, selectors); + } + } + return result; + }, + + async getAssetStylesheetSelectors() { + const result = new Map(); + await documentManager.preload(config.rootUri); + + const assetsUri = path.join(config.rootUri, 'assets'); + const cssFiles = await recursiveReadDirectory( + fs, + assetsUri, + ([uri]) => uri.endsWith('.css'), + ); + + for (const fileUri of cssFiles) { + const cssContent = await fs.readFile(fileUri); + const relativePath = path.relative(fileUri, config.rootUri); + const stylesheet = extractStylesheetFromCSS(fileUri, cssContent); + result.set(relativePath, stylesheet); + } + return result; + }, }); const offenses = [...themeOffenses, ...cssOffenses]; diff --git a/packages/theme-language-server-common/src/documents/DocumentManager.ts b/packages/theme-language-server-common/src/documents/DocumentManager.ts index 6102b6e25..514e7140e 100644 --- a/packages/theme-language-server-common/src/documents/DocumentManager.ts +++ b/packages/theme-language-server-common/src/documents/DocumentManager.ts @@ -19,7 +19,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { ClientCapabilities } from '../ClientCapabilities'; import { percent, Progress } from '../progress'; import { AugmentedSourceCode } from './types'; -import { extractDocDefinition } from '@shopify/theme-check-common'; +import { extractDocDefinition, extractStylesheetSelectors } from '@shopify/theme-check-common'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -201,6 +201,13 @@ export class DocumentManager { return extractDocDefinition(uri, ast); }), + /** Lazy and only computed once per file version */ + getStylesheetSelectors: memo(async () => { + const ast = sourceCode.ast; + if (isError(ast)) return undefined; + + return extractStylesheetSelectors(uri, ast); + }), }; default: return assertNever(sourceCode); diff --git a/packages/theme-language-server-common/src/documents/types.ts b/packages/theme-language-server-common/src/documents/types.ts index af9ed4e10..7108d98e2 100644 --- a/packages/theme-language-server-common/src/documents/types.ts +++ b/packages/theme-language-server-common/src/documents/types.ts @@ -4,9 +4,10 @@ import { SectionSchema, ThemeBlockSchema, AppBlockSchema, + DocDefinition, + Stylesheet, } from '@shopify/theme-check-common'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { DocDefinition } from '@shopify/theme-check-common'; /** Util type to add the common `textDocument` property to the SourceCode. */ type _AugmentedSourceCode = SourceCode & { @@ -25,6 +26,7 @@ export type AugmentedJsonSourceCode = _AugmentedSourceCode; export type AugmentedLiquidSourceCode = _AugmentedSourceCode & { getSchema: () => Promise; getLiquidDoc: () => Promise; + getStylesheetSelectors: () => Promise; }; /** diff --git a/yarn.lock b/yarn.lock index b49dbd493..b31ea79d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1373,6 +1373,13 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/postcss-safe-parser@^5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/postcss-safe-parser/-/postcss-safe-parser-5.0.4.tgz#2913271fb07e62b8829753e809f011be967d6652" + integrity sha512-5zGTm1jsW3j4+omgND1SIDbrZOcigTuxa4ihppvKbLkg2INUGBHV/fWNRSRFibK084tU3fxqZ/kVoSIGqRHnrQ== + dependencies: + postcss "^8.4.4" + "@types/prettier@^2.4.2": version "2.7.3" resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" @@ -5800,6 +5807,11 @@ mute-stream@~0.0.4: resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + nanoid@^3.3.7: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" @@ -6347,6 +6359,11 @@ postcss-modules-values@^4.0.0: dependencies: icss-utils "^5.0.0" +postcss-safe-parser@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz#36e4f7e608111a0ca940fd9712ce034718c40ec0" + integrity sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A== + postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4: version "6.0.13" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz#d05d8d76b1e8e173257ef9d60b706a8e5e99bf1b" @@ -6355,6 +6372,14 @@ postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4: cssesc "^3.0.0" util-deprecate "^1.0.2" +postcss-selector-parser@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz#e75d2e0d843f620e5df69076166f4e16f891cb9f" + integrity sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" @@ -6369,6 +6394,15 @@ postcss@^8.4.21, postcss@^8.4.43: picocolors "^1.1.1" source-map-js "^1.2.1" +postcss@^8.4.4, postcss@^8.4.49: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + prebuild-install@^7.0.1: version "7.1.1" resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz"