diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e6d85fe..b0b55b0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,22 +5,14 @@ "tasks": [ { "label": "watch", - "dependsOn": ["npm: watch:esbuild"], - "presentation": { - "reveal": "never" - }, + "type": "npm", + "script": "watch:esbuild", "group": { "kind": "build", "isDefault": true - } - }, - { - "type": "npm", - "script": "watch:esbuild", - "group": "build", + }, "problemMatcher": "$esbuild-watch", "isBackground": true, - "label": "npm: watch:esbuild", "presentation": { "group": "watch", "reveal": "never" diff --git a/package.json b/package.json index 5ad060a..3b20bd9 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,11 @@ "type": "boolean", "default": true, "description": "Enable hover decorations for Magento 2 XML files." + }, + "magento-toolbox.provideThemeDefinitions": { + "type": "boolean", + "default": true, + "description": "Enable definitions for Magento 2 theme files." } } }, diff --git a/src/definition/XmlDefinitionProviderProcessor.ts b/src/definition/XmlDefinitionProviderProcessor.ts index d2af282..5a19cf0 100644 --- a/src/definition/XmlDefinitionProviderProcessor.ts +++ b/src/definition/XmlDefinitionProviderProcessor.ts @@ -9,6 +9,7 @@ import { XmlSuggestionProviderProcessor } from 'common/xml/XmlSuggestionProvider import { AclDefinitionProvider } from './xml/AclDefinitionProvider'; import { ModuleDefinitionProvider } from './xml/ModuleDefinitionProvider'; import { TemplateDefinitionProvider } from './xml/TemplateDefinitionProvider'; +import { ThemeDefinitionProvider } from './xml/ThemeDefinitionProvider'; export class XmlDefinitionProviderProcessor extends XmlSuggestionProviderProcessor @@ -19,6 +20,7 @@ export class XmlDefinitionProviderProcessor new AclDefinitionProvider(), new ModuleDefinitionProvider(), new TemplateDefinitionProvider(), + new ThemeDefinitionProvider(), ]); } diff --git a/src/definition/xml/TemplateDefinitionProvider.ts b/src/definition/xml/TemplateDefinitionProvider.ts index 3651789..561096d 100644 --- a/src/definition/xml/TemplateDefinitionProvider.ts +++ b/src/definition/xml/TemplateDefinitionProvider.ts @@ -8,7 +8,7 @@ import { ElementAttributeMatches } from 'common/xml/suggestion/condition/Element export class TemplateDefinitionProvider extends XmlSuggestionProvider { public getFilePatterns(): string[] { - return ['**/view/**/layout/*.xml', '**/etc/**/di.xml']; + return ['**/layout/*.xml', '**/etc/**/di.xml']; } public getAttributeValueConditions(): CombinedCondition[] { diff --git a/src/definition/xml/ThemeDefinitionProvider.ts b/src/definition/xml/ThemeDefinitionProvider.ts new file mode 100644 index 0000000..af9a39d --- /dev/null +++ b/src/definition/xml/ThemeDefinitionProvider.ts @@ -0,0 +1,48 @@ +import { LocationLink, Uri, Range, TextDocument } from 'vscode'; +import IndexManager from 'indexer/IndexManager'; +import { XmlSuggestionProvider, CombinedCondition } from 'common/xml/XmlSuggestionProvider'; +import { XMLElement, XMLAttribute } from '@xml-tools/ast'; +import { ElementNameMatches } from 'common/xml/suggestion/condition/ElementNameMatches'; +import ThemeIndexer from 'indexer/theme/ThemeIndexer'; + +export class ThemeDefinitionProvider extends XmlSuggestionProvider { + public getFilePatterns(): string[] { + return ['**/theme.xml']; + } + + public getElementContentMatches(): CombinedCondition[] { + return [[new ElementNameMatches('parent')]]; + } + + public getConfigKey(): string | undefined { + return 'provideThemeDefinitions'; + } + + public getSuggestionItems( + value: string, + range: Range, + document: TextDocument, + element: XMLElement, + attribute?: XMLAttribute + ): LocationLink[] { + const themeIndexData = IndexManager.getIndexData(ThemeIndexer.KEY); + + if (!themeIndexData) { + return []; + } + + const theme = themeIndexData.getThemeById(value); + + if (!theme) { + return []; + } + + return [ + { + targetUri: Uri.file(theme.path), + targetRange: new Range(0, 0, 0, 0), + originSelectionRange: range, + }, + ]; + } +} diff --git a/src/extension.ts b/src/extension.ts index 3f0ea06..20cbd85 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -81,12 +81,6 @@ export async function activate(context: vscode.ExtensionContext) { DocumentCache.clear(event); }), vscode.workspace.onDidSaveTextDocument(textDocument => { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(textDocument.uri); - - if (workspaceFolder) { - IndexRunner.indexFile(workspaceFolder, textDocument.uri); - } - DocumentCache.clear(textDocument); }) ); diff --git a/src/generator/module/ModuleXmlGenerator.ts b/src/generator/module/ModuleXmlGenerator.ts index 633e627..b3e242d 100644 --- a/src/generator/module/ModuleXmlGenerator.ts +++ b/src/generator/module/ModuleXmlGenerator.ts @@ -4,6 +4,9 @@ import { Uri } from 'vscode'; import { ModuleWizardComposerData, ModuleWizardData } from 'wizard/ModuleWizard'; import FileGenerator from '../FileGenerator'; import Magento from 'util/Magento'; +import HandlebarsTemplateRenderer from 'generator/HandlebarsTemplateRenderer'; +import { TemplatePath } from 'types/handlebars'; +import FileHeader from 'common/xml/FileHeader'; export default class ModuleXmlGenerator extends FileGenerator { public constructor(protected data: ModuleWizardData | ModuleWizardComposerData) { @@ -11,7 +14,7 @@ export default class ModuleXmlGenerator extends FileGenerator { } public async generate(workspaceUri: Uri): Promise { - const xmlContent = this.getXmlContent(); + const xmlContent = await this.getXmlContent(); const moduleFile = Magento.getModuleDirectory( this.data.vendor, @@ -23,33 +26,17 @@ export default class ModuleXmlGenerator extends FileGenerator { return new GeneratedFile(moduleFile, xmlContent); } - protected getXmlContent(): string { + protected async getXmlContent(): Promise { + const renderer = new HandlebarsTemplateRenderer(); const moduleName = Magento.getModuleName(this.data.vendor, this.data.module); - const xml: any = { - '?xml': { - '@_version': '1.0', - }, - config: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@_xsi:noNamespaceSchemaLocation': 'urn:magento:framework:Module/etc/module.xsd', - module: { - '@_name': moduleName, - }, - }, - }; + const fileHeader = FileHeader.getHeader(moduleName); - if (this.data.sequence.length > 0) { - xml.config.module.sequence = this.data.sequence.map(module => ({ - module: { - '@_name': module, - }, - })); - } - - const xmlGenerator = new XmlGenerator(xml); - return xmlGenerator.toString({ - unpairedTags: ['module'], - suppressUnpairedNode: false, + const moduleConfigXml = await renderer.render(TemplatePath.XmlModuleConfig, { + moduleName, + sequence: this.data.sequence, + fileHeader, }); + + return moduleConfigXml; } } diff --git a/src/hover/XmlHoverProviderProcessor.ts b/src/hover/XmlHoverProviderProcessor.ts index fafd1d8..fa5767b 100644 --- a/src/hover/XmlHoverProviderProcessor.ts +++ b/src/hover/XmlHoverProviderProcessor.ts @@ -3,10 +3,16 @@ import { XmlSuggestionProviderProcessor } from 'common/xml/XmlSuggestionProvider import { AclHoverProvider } from 'hover/xml/AclHoverProvider'; import { ModuleHoverProvider } from 'hover/xml/ModuleHoverProvider'; import { CronHoverProvider } from 'hover/xml/CronHoverProvider'; +import { ThemeHoverProvider } from 'hover/xml/ThemeHoverProvider'; export class XmlHoverProviderProcessor extends XmlSuggestionProviderProcessor { public constructor() { - super([new AclHoverProvider(), new ModuleHoverProvider(), new CronHoverProvider()]); + super([ + new AclHoverProvider(), + new ModuleHoverProvider(), + new CronHoverProvider(), + new ThemeHoverProvider(), + ]); } public async provideHover( diff --git a/src/hover/xml/ThemeHoverProvider.ts b/src/hover/xml/ThemeHoverProvider.ts new file mode 100644 index 0000000..4d1f58a --- /dev/null +++ b/src/hover/xml/ThemeHoverProvider.ts @@ -0,0 +1,63 @@ +import { Hover, MarkdownString, Uri, Range, workspace, TextDocument } from 'vscode'; +import IndexManager from 'indexer/IndexManager'; +import { CombinedCondition, XmlSuggestionProvider } from 'common/xml/XmlSuggestionProvider'; +import { ElementNameMatches } from 'common/xml/suggestion/condition/ElementNameMatches'; +import { XMLElement, XMLAttribute } from '@xml-tools/ast'; +import ThemeIndexer from 'indexer/theme/ThemeIndexer'; +import path from 'path'; + +export class ThemeHoverProvider extends XmlSuggestionProvider { + public getElementContentMatches(): CombinedCondition[] { + return [[new ElementNameMatches('parent')]]; + } + + public getConfigKey(): string | undefined { + return 'provideXmlHovers'; + } + + public getFilePatterns(): string[] { + return ['**/theme.xml']; + } + + public getSuggestionItems( + value: string, + range: Range, + document: TextDocument, + element: XMLElement, + attribute?: XMLAttribute + ): Hover[] { + const workspaceFolder = workspace.getWorkspaceFolder(document.uri); + + if (!workspaceFolder) { + return []; + } + + const themeIndexData = IndexManager.getIndexData(ThemeIndexer.KEY); + + if (!themeIndexData) { + return []; + } + + const theme = themeIndexData.getThemeById(value); + + if (!theme) { + return []; + } + + const markdown = new MarkdownString(); + markdown.appendMarkdown(`**Theme**: ${theme.title}\n\n`); + markdown.appendMarkdown(`- ID: \`${theme.id}\`\n\n`); + + const relativePath = path.relative(workspaceFolder.uri.fsPath, theme.path); + + markdown.appendMarkdown(`- Path: \`${relativePath}\`\n\n`); + + if (theme.parent) { + markdown.appendMarkdown(`- Parent: \n\n - ${theme.parent}\n\n`); + } + + markdown.appendMarkdown(`[theme.xml](${Uri.file(theme.path)})`); + + return [new Hover(markdown, range)]; + } +} diff --git a/src/indexer/IndexManager.ts b/src/indexer/IndexManager.ts index 00f9fed..ac63a30 100644 --- a/src/indexer/IndexManager.ts +++ b/src/indexer/IndexManager.ts @@ -1,4 +1,4 @@ -import { Progress, Uri, workspace, WorkspaceFolder } from 'vscode'; +import { FileSystemWatcher, Progress, Uri, workspace, WorkspaceFolder } from 'vscode'; import { Indexer } from './Indexer'; import Common from 'util/Common'; import { minimatch } from 'minimatch'; @@ -20,6 +20,10 @@ import TemplateIndexer from './template/TemplateIndexer'; import { TemplateIndexData } from './template/TemplateIndexData'; import CronIndexer from './cron/CronIndexer'; import { CronIndexData } from './cron/CronIndexData'; +import LayoutIndexer from './layout/LayoutIndexer'; +import { LayoutIndexData } from './layout/LayoutIndexData'; +import ThemeIndexer from './theme/ThemeIndexer'; +import { ThemeIndexData } from './theme/ThemeIndexData'; type IndexerInstance = | DiIndexer @@ -28,7 +32,9 @@ type IndexerInstance = | EventsIndexer | AclIndexer | TemplateIndexer - | CronIndexer; + | CronIndexer + | LayoutIndexer + | ThemeIndexer; type IndexerDataMap = { [DiIndexer.KEY]: DiIndexData; @@ -38,6 +44,8 @@ type IndexerDataMap = { [AclIndexer.KEY]: AclIndexData; [TemplateIndexer.KEY]: TemplateIndexData; [CronIndexer.KEY]: CronIndexData; + [ThemeIndexer.KEY]: ThemeIndexData; + [LayoutIndexer.KEY]: LayoutIndexData; }; class IndexManager { @@ -45,6 +53,7 @@ class IndexManager { protected indexers: IndexerInstance[] = []; protected indexStorage: IndexStorage; + protected fileWatchers: Record> = {}; public constructor() { this.indexers = [ @@ -55,6 +64,8 @@ class IndexManager { new AclIndexer(), new TemplateIndexer(), new CronIndexer(), + new ThemeIndexer(), + new LayoutIndexer(), ]; this.indexStorage = new IndexStorage(); } @@ -77,13 +88,20 @@ class IndexManager { Logger.logWithTime('Indexing workspace', workspaceFolder.name); for (const indexer of this.indexers) { + progress.report({ + message: `Indexing - ${indexer.getName()} [loading index]`, + increment: 0, + }); await this.indexStorage.loadIndex(workspaceFolder, indexer.getId(), indexer.getVersion()); if (!force && !this.shouldIndex(workspaceFolder, indexer)) { Logger.logWithTime('Loaded index from storage', workspaceFolder.name, indexer.getId()); continue; } - progress.report({ message: `Indexing - ${indexer.getName()}`, increment: 0 }); + progress.report({ + message: `Indexing - ${indexer.getName()} [discovering files]`, + increment: 0, + }); const indexData = this.getIndexStorageData(indexer.getId()) || new Map(); @@ -98,6 +116,10 @@ class IndexManager { await Promise.all( batch.map(async file => { + if (!indexer.canIndex(file)) { + return; + } + const data = await indexer.indexFile(file); if (data !== undefined) { @@ -195,6 +217,12 @@ class IndexManager { case CronIndexer.KEY: return new CronIndexData(data) as IndexerDataMap[T]; + case ThemeIndexer.KEY: + return new ThemeIndexData(data) as IndexerDataMap[T]; + + case LayoutIndexer.KEY: + return new LayoutIndexData(data) as IndexerDataMap[T]; + default: return undefined; } @@ -220,9 +248,60 @@ class IndexManager { clear([indexer.getId()]); } + protected async removeFileFromIndex( + workspaceFolder: WorkspaceFolder, + file: Uri, + indexer: Indexer + ) { + const indexData = this.getIndexStorageData(indexer.getId()) || new Map(); + indexData.delete(file.fsPath); + this.indexStorage.set(workspaceFolder, indexer.getId(), indexData); + await this.indexStorage.saveIndex(workspaceFolder, indexer.getId(), indexer.getVersion()); + } + protected shouldIndex(workspaceFolder: WorkspaceFolder, index: IndexerInstance): boolean { return !this.indexStorage.hasIndex(workspaceFolder, index.getId()); } + + public watchFiles(workspaceFolder: WorkspaceFolder) { + Logger.logWithTime('Watching files for workspace', workspaceFolder.uri.fsPath); + + if (!this.fileWatchers[workspaceFolder.uri.fsPath]) { + this.fileWatchers[workspaceFolder.uri.fsPath] = {}; + } + + for (const indexer of this.indexers) { + const pattern = indexer.getPattern(workspaceFolder.uri); + const patternString = typeof pattern === 'string' ? pattern : pattern.pattern; + + let watcher: FileSystemWatcher | undefined = + this.fileWatchers[workspaceFolder.uri.fsPath][indexer.getId()]; + + if (watcher) { + watcher.dispose(); + } + + watcher = workspace.createFileSystemWatcher(patternString, false, false, false); + + watcher.onDidChange(file => { + this.indexFileInner(workspaceFolder, file, indexer); + + Logger.logWithTime('File changed', file.fsPath); + }); + + watcher.onDidCreate(file => { + this.indexFileInner(workspaceFolder, file, indexer); + + Logger.logWithTime('File created', file.fsPath); + }); + + watcher.onDidDelete(file => { + this.removeFileFromIndex(workspaceFolder, file, indexer); + + Logger.logWithTime('File deleted', file.fsPath); + }); + } + } } export default new IndexManager(); diff --git a/src/indexer/IndexRunner.ts b/src/indexer/IndexRunner.ts index 83154a0..8587d83 100644 --- a/src/indexer/IndexRunner.ts +++ b/src/indexer/IndexRunner.ts @@ -17,6 +17,7 @@ class IndexRunner { }, async progress => { await IndexManager.indexWorkspace(workspaceFolder, progress, force); + IndexManager.watchFiles(workspaceFolder); } ); } diff --git a/src/indexer/Indexer.ts b/src/indexer/Indexer.ts index e7afdd4..872352c 100644 --- a/src/indexer/Indexer.ts +++ b/src/indexer/Indexer.ts @@ -5,6 +5,9 @@ export abstract class Indexer { public abstract getId(): IndexerKey; public abstract getName(): string; public abstract getPattern(uri: Uri): GlobPattern; + public canIndex(uri: Uri): boolean { + return true; + } public abstract indexFile(uri: Uri): Promise; public abstract getVersion(): number; } diff --git a/src/indexer/layout/LayoutIndexData.ts b/src/indexer/layout/LayoutIndexData.ts new file mode 100644 index 0000000..4c9ff4a --- /dev/null +++ b/src/indexer/layout/LayoutIndexData.ts @@ -0,0 +1,13 @@ +import { Memoize } from 'typescript-memoize'; +import { Layout } from './types'; +import { AbstractIndexData } from 'indexer/AbstractIndexData'; +import LayoutIndexer from './LayoutIndexer'; + +export class LayoutIndexData extends AbstractIndexData { + @Memoize({ + tags: [LayoutIndexer.KEY], + }) + public getLayouts(): Layout[] { + return Array.from(this.data.values()).flat(); + } +} diff --git a/src/indexer/layout/LayoutIndexer.ts b/src/indexer/layout/LayoutIndexer.ts new file mode 100644 index 0000000..89b4b95 --- /dev/null +++ b/src/indexer/layout/LayoutIndexer.ts @@ -0,0 +1,215 @@ +import { RelativePattern, Uri } from 'vscode'; +import { Indexer } from 'indexer/Indexer'; +import { IndexerKey } from 'types/indexer'; +import { Layout } from './types'; +import { XMLParser } from 'fast-xml-parser'; +import FileSystem from 'util/FileSystem'; +import IndexManager from 'indexer/IndexManager'; +import ThemeIndexer from 'indexer/theme/ThemeIndexer'; +import { Theme } from 'indexer/theme/types'; + +export default class LayoutIndexer extends Indexer { + public static readonly KEY = 'layout'; + + private xmlParser: XMLParser; + + public constructor() { + super(); + + this.xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: () => { + return true; + }, + }); + } + + public getVersion(): number { + return 1; + } + + public getId(): IndexerKey { + return LayoutIndexer.KEY; + } + + public getName(): string { + return 'layout'; + } + + public getPattern(uri: Uri): RelativePattern { + return new RelativePattern(uri, '**/layout/*.xml'); + } + + public async indexFile(uri: Uri): Promise { + const xml = await FileSystem.readFile(uri); + const parsed = this.xmlParser.parse(xml); + + const pageNode = Array.isArray(parsed?.page) ? parsed.page[0] : undefined; + + if (!pageNode) { + return undefined; + } + + const getAttr = (node: any, name: string): string | undefined => { + const value = node?.[`@_${name}`]; + if (Array.isArray(value)) { + return value[0]; + } + return value; + }; + + const getBoolAttr = (node: any, name: string): boolean | undefined => { + const raw = getAttr(node, name); + if (raw === undefined) { + return undefined; + } + const lowered = String(raw).toLowerCase(); + if (lowered === 'true' || lowered === '1') { + return true; + } + if (lowered === 'false' || lowered === '0') { + return false; + } + return undefined; + }; + + const getNumAttr = (node: any, name: string): number | undefined => { + const raw = getAttr(node, name); + if (raw === undefined) { + return undefined; + } + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + }; + + const mapUiComponents = (nodes: any[] | undefined) => { + if (!Array.isArray(nodes)) { + return []; + } + return nodes.map(node => ({ + name: getAttr(node, 'name'), + component: getAttr(node, 'component'), + as: getAttr(node, 'as'), + ttl: getNumAttr(node, 'ttl'), + group: getAttr(node, 'group'), + acl: getAttr(node, 'acl'), + cacheable: getBoolAttr(node, 'cacheable'), + })); + }; + + const mapBlocks = (nodes: any[] | undefined): any[] => { + if (!Array.isArray(nodes)) { + return []; + } + return nodes.map(node => ({ + name: getAttr(node, 'name'), + class: getAttr(node, 'class'), + cacheable: getBoolAttr(node, 'cacheable'), + as: getAttr(node, 'as'), + ttl: getNumAttr(node, 'ttl'), + group: getAttr(node, 'group'), + acl: getAttr(node, 'acl'), + block: mapBlocks(node.block), + container: mapContainers(node.container), + referenceBlock: mapReferenceBlocks(node.referenceBlock), + uiComponent: mapUiComponents(node.uiComponent), + })); + }; + + const mapReferenceBlocks = (nodes: any[] | undefined): any[] => { + if (!Array.isArray(nodes)) { + return []; + } + return nodes.map(node => ({ + name: getAttr(node, 'name') as string, + template: getAttr(node, 'template'), + class: getAttr(node, 'class'), + group: getAttr(node, 'group'), + display: getBoolAttr(node, 'display'), + remove: getBoolAttr(node, 'remove'), + block: mapBlocks(node.block), + referenceBlock: mapReferenceBlocks(node.referenceBlock), + uiComponent: mapUiComponents(node.uiComponent), + container: mapContainers(node.container), + })); + }; + + const mapContainers = (nodes: any[] | undefined): any[] => { + if (!Array.isArray(nodes)) { + return []; + } + return nodes.map(node => ({ + name: getAttr(node, 'name') as string, + after: getAttr(node, 'after'), + before: getAttr(node, 'before'), + block: mapBlocks(node.block), + referenceBlock: mapReferenceBlocks(node.referenceBlock), + uiComponent: mapUiComponents(node.uiComponent), + container: mapContainers(node.container), + })); + }; + + const mapMoves = (nodes: any[] | undefined) => { + if (!Array.isArray(nodes)) { + return []; + } + return nodes.map(node => ({ + element: getAttr(node, 'element') as string, + destination: getAttr(node, 'destination') as string, + as: getAttr(node, 'as'), + after: getAttr(node, 'after'), + before: getAttr(node, 'before'), + })); + }; + + const bodyNode = Array.isArray(pageNode.body) ? pageNode.body[0] : undefined; + const body = bodyNode + ? { + block: mapBlocks(bodyNode.block), + referenceBlock: mapReferenceBlocks(bodyNode.referenceBlock), + uiComponent: mapUiComponents(bodyNode.uiComponent), + container: mapContainers(bodyNode.container), + move: mapMoves(bodyNode.move), + } + : { block: [], referenceBlock: [], uiComponent: [], container: [], move: [] }; + + const page = { + update: Array.isArray(pageNode.update) + ? pageNode.update.map((u: any) => ({ + handle: getAttr(u, 'handle') as string, + })) + : [], + body: [body], + }; + + const path = uri.fsPath; + const p = path.replace(/\\/g, '/'); + + let area = 'base'; + if (p.includes('/view/frontend/layout/') || p.includes('/app/design/frontend/')) { + area = 'frontend'; + } else if (p.includes('/view/adminhtml/layout/') || p.includes('/app/design/adminhtml/')) { + area = 'adminhtml'; + } else if (p.includes('/view/base/layout/') || p.includes('/app/design/base/')) { + area = 'base'; + } + + const theme = this.getTheme(path); + + const layout: Layout = { + area, + theme: theme?.title ?? '-', + path, + page, + }; + + return layout; + } + + private getTheme(path: string): Theme | undefined { + const themeIndexData = IndexManager.getIndexData(ThemeIndexer.KEY); + + return themeIndexData?.getThemeByFilePath(path); + } +} diff --git a/src/indexer/layout/types.ts b/src/indexer/layout/types.ts new file mode 100644 index 0000000..f27ab76 --- /dev/null +++ b/src/indexer/layout/types.ts @@ -0,0 +1,96 @@ +interface Block { + name?: string; + class?: string; + cacheable?: boolean; + as?: string; + ttl?: number; + group?: string; + acl?: string; + + block: Block[]; + container: Container[]; + referenceBlock: BlockReference[]; + uiComponent: UiComponent[]; +} + +interface BlockReference extends Block { + name: string; + template?: string; + class?: string; + group?: string; + display?: boolean; + remove?: boolean; + + block: Block[]; + referenceBlock: BlockReference[]; + uiComponent: UiComponent[]; + container: Container[]; +} + +interface Container { + name: string; + after?: string; + before?: string; + + block: Block[]; + referenceBlock: BlockReference[]; + uiComponent: UiComponent[]; + container: Container[]; +} + +interface ContainerReference { + name: string; + remove?: boolean; + display?: boolean; + + block: Block[]; + referenceBlock: BlockReference[]; + uiComponent: UiComponent[]; + container: Container[]; +} + +interface UiComponent { + name?: string; + component?: string; + as?: string; + ttl?: number; + group?: string; + acl?: string; + cacheable?: boolean; +} + +interface Update { + handle: string; +} + +interface Remove { + name: string; +} + +interface Move { + element: string; + destination: string; + as?: string; + after?: string; + before?: string; +} + +interface Body { + block: Block[]; + referenceBlock: BlockReference[]; + uiComponent: UiComponent[]; + container: Container[]; + move: Move[]; +} + +interface Page { + update: Update[]; + body: Body[]; +} + +export interface Layout { + area: string; + theme: string; + path: string; + page: Page; +} diff --git a/src/indexer/theme/ThemeIndexData.ts b/src/indexer/theme/ThemeIndexData.ts new file mode 100644 index 0000000..4169eab --- /dev/null +++ b/src/indexer/theme/ThemeIndexData.ts @@ -0,0 +1,24 @@ +import { Memoize } from 'typescript-memoize'; +import { Theme } from './types'; +import { AbstractIndexData } from 'indexer/AbstractIndexData'; +import ThemeIndexer from './ThemeIndexer'; + +export class ThemeIndexData extends AbstractIndexData { + @Memoize({ tags: [ThemeIndexer.KEY] }) + public getThemes(): Theme[] { + return this.getValues().flat(); + } + + public getThemeByFilePath(path: string): Theme | undefined { + const normalized = path.replace(/\\/g, '/'); + + return this.getThemes().find(theme => { + const base = theme.basePath.replace(/\\/g, '/'); + return normalized.startsWith(base); + }); + } + + public getThemeById(id: string): Theme | undefined { + return this.getThemes().find(theme => theme.id.toLowerCase() === id.toLowerCase()); + } +} diff --git a/src/indexer/theme/ThemeIndexer.ts b/src/indexer/theme/ThemeIndexer.ts new file mode 100644 index 0000000..712e317 --- /dev/null +++ b/src/indexer/theme/ThemeIndexer.ts @@ -0,0 +1,92 @@ +import { RelativePattern, Uri } from 'vscode'; +import { XMLParser } from 'fast-xml-parser'; +import { get } from 'lodash-es'; +import { Indexer } from 'indexer/Indexer'; +import FileSystem from 'util/FileSystem'; +import { IndexerKey } from 'types/indexer'; +import { Theme } from './types'; + +export default class ThemeIndexer extends Indexer { + public static readonly KEY = 'theme'; + + private xmlParser: XMLParser; + + public constructor() { + super(); + + this.xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }); + } + + public getVersion(): number { + return 1; + } + + public getId(): IndexerKey { + return ThemeIndexer.KEY; + } + + public getName(): string { + return 'theme.xml'; + } + + public getPattern(uri: Uri): RelativePattern { + return new RelativePattern(uri, '**/theme.xml'); + } + + public canIndex(uri: Uri): boolean { + return !uri.fsPath.includes('Test/Unit') && !uri.fsPath.includes('dev/tests'); + } + + public async indexFile(uri: Uri): Promise { + const id = await this.getThemeId(uri); + + if (!id) { + return undefined; + } + + const xml = await FileSystem.readFile(uri); + const parsed = this.xmlParser.parse(xml); + + const titleRaw = get(parsed, 'theme.title'); + const parentRaw = get(parsed, 'theme.parent'); + + const title = Array.isArray(titleRaw) ? titleRaw[0] : titleRaw; + const parent = Array.isArray(parentRaw) ? parentRaw[0] : parentRaw; + + if (!title || typeof title !== 'string') { + return undefined; + } + + const theme: Theme = { + title, + id, + path: uri.fsPath, + basePath: Uri.joinPath(uri, '..').fsPath, + parent: typeof parent === 'string' ? parent : undefined, + }; + + return theme; + } + + private async getThemeId(uri: Uri): Promise { + const registrationUri = Uri.joinPath(uri, '../registration.php'); + + let registration: string | undefined; + try { + registration = await FileSystem.readFile(registrationUri); + } catch (error) { + return undefined; + } + + const themeId = registration.match(/'frontend\/(.+)'/); + + if (!themeId) { + return undefined; + } + + return themeId[1]; + } +} diff --git a/src/indexer/theme/types.ts b/src/indexer/theme/types.ts new file mode 100644 index 0000000..b59e421 --- /dev/null +++ b/src/indexer/theme/types.ts @@ -0,0 +1,7 @@ +export interface Theme { + title: string; + id: string; + path: string; + basePath: string; + parent?: string; +} diff --git a/src/test/generator/module/ModuleXmlGenerator.test.ts b/src/test/generator/module/ModuleXmlGenerator.test.ts index 87bd687..34949e0 100644 --- a/src/test/generator/module/ModuleXmlGenerator.test.ts +++ b/src/test/generator/module/ModuleXmlGenerator.test.ts @@ -6,6 +6,7 @@ import ModuleXmlGenerator from 'generator/module/ModuleXmlGenerator'; import { describe, it, before, afterEach } from 'mocha'; import { setup } from 'test/setup'; import { getReferenceFile, getTestWorkspaceUri } from 'test/util'; +import FileHeader from 'common/xml/FileHeader'; import sinon from 'sinon'; describe('ModuleXmlGenerator Tests', () => { @@ -82,4 +83,28 @@ describe('ModuleXmlGenerator Tests', () => { // Compare the generated content with reference assert.strictEqual(generatedFile.content, referenceContent); }); + + it('should generate module.xml with comment', async () => { + sinon.stub(FileHeader, 'getHeader').returns(''); + + // Create test data with sequence + const dataWithSequence: ModuleWizardData = { + ...moduleWizardData, + }; + + // Create the generator with sequence data + const generator = new ModuleXmlGenerator(dataWithSequence); + + // Use a test workspace URI + const workspaceUri = getTestWorkspaceUri(); + + // Generate the file + const generatedFile = await generator.generate(workspaceUri); + + // Get the reference file content + const referenceContent = getReferenceFile('generator/module/module-with-comment.xml'); + + // Compare the generated content with reference + assert.strictEqual(generatedFile.content, referenceContent); + }); }); diff --git a/src/types/handlebars.ts b/src/types/handlebars.ts index 28d187e..690aec2 100644 --- a/src/types/handlebars.ts +++ b/src/types/handlebars.ts @@ -28,6 +28,7 @@ export enum TemplatePath { XmlBlankWidget = 'xml/blank-widget', XmlEventsObserver = 'xml/events/observer', XmlEventsEvent = 'xml/events/event', + XmlModuleConfig = 'xml/module-config', XmlCronJob = 'xml/cron/job', XmlCronGroup = 'xml/cron/group', } @@ -120,6 +121,14 @@ export interface CronGroupParams extends BaseTemplateParams { groupId: string; } +/** + * Parameters for module config templates + */ +export interface ModuleConfigParams extends BaseTemplateParams { + moduleName: string; + sequence: string[]; +} + /** * Template parameters mapped by template path */ @@ -137,6 +146,7 @@ export interface TemplateParams { [TemplatePath.XmlDiPreference]: PreferenceParams; [TemplatePath.XmlCronJob]: CronJobParams; [TemplatePath.XmlCronGroup]: CronGroupParams; + [TemplatePath.XmlModuleConfig]: ModuleConfigParams; [key: string]: BaseTemplateParams; } diff --git a/templates/handlebars/xml/module-config.hbs b/templates/handlebars/xml/module-config.hbs new file mode 100644 index 0000000..68669ae --- /dev/null +++ b/templates/handlebars/xml/module-config.hbs @@ -0,0 +1,16 @@ + +{{> fileHeader}} + +{{#if sequence}} + + + {{#each sequence}} + + {{/each}} + + +{{else}} + +{{/if}} + diff --git a/test-resources/reference/generator/module/module-with-comment.xml b/test-resources/reference/generator/module/module-with-comment.xml index ebf3484..cfa8583 100644 --- a/test-resources/reference/generator/module/module-with-comment.xml +++ b/test-resources/reference/generator/module/module-with-comment.xml @@ -1,7 +1,8 @@ - +Foo_Bar +--> + diff --git a/test-resources/reference/generator/module/module-with-sequence.xml b/test-resources/reference/generator/module/module-with-sequence.xml index 9450bcd..c66ea7a 100644 --- a/test-resources/reference/generator/module/module-with-sequence.xml +++ b/test-resources/reference/generator/module/module-with-sequence.xml @@ -1,5 +1,6 @@ - + diff --git a/test-resources/reference/generator/module/module.xml b/test-resources/reference/generator/module/module.xml index 22316b9..1e9d56a 100644 --- a/test-resources/reference/generator/module/module.xml +++ b/test-resources/reference/generator/module/module.xml @@ -1,4 +1,5 @@ - +