diff --git a/README.md b/README.md index 5a29822..e406d6b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,41 @@ Here a some common patterns for different project types: | Laravel + Vue | `resources/js/Pages/**/*.vue` | | Laravel + React | `resources/js/Pages/**/*.tsx` | +### `inertia.pageResolvers` (New Feature) + +For modular applications (e.g., using Laravel Modules), you can configure multiple page resolvers with prefixes. Each resolver maps a prefix to a specific glob pattern, allowing you to organize pages across different modules. + +```json +{ + "inertia.pageResolvers": [ + { + "prefix": "Shipping", + "pattern": "Modules/Shipping/Pages/**/*.tsx" + }, + { + "prefix": "Accounting", + "pattern": "Modules/Accounting/Pages/**/*.tsx" + } + ] +} +``` + +With this configuration, you can reference components using prefixes in your controllers: + +```php +// In your controller +return inertia('Shipping:Orders/Index'); // Points to Modules/Shipping/Pages/Orders/Index.tsx +return inertia('Accounting:Reports/Balance'); // Points to Modules/Accounting/Pages/Reports/Balance.tsx +``` + +**Key benefits:** +- **Modular organization**: Each module has its own page directory +- **Prefix-based resolution**: Use module prefixes to avoid naming conflicts +- **Autocompletion support**: Get intelligent suggestions for all modules +- **Hyperlink navigation**: Click to navigate directly to module pages + +**Note**: When using `pageResolvers`, the traditional `pages` setting is optional and serves as a fallback for non-prefixed components. + ### `inertia.pathSeparators` Inertia.js commonly uses a forward slash when it comes to component names diff --git a/example-settings.json b/example-settings.json new file mode 100644 index 0000000..b3966ce --- /dev/null +++ b/example-settings.json @@ -0,0 +1,18 @@ +{ + "inertia.pageResolvers": [ + { + "prefix": "Shipping", + "pattern": "Modules/Shipping/Pages/**/*.tsx" + }, + { + "prefix": "Accounting", + "pattern": "Modules/Accounting/Pages/**/*.tsx" + }, + { + "prefix": "Inventory", + "pattern": "Modules/Inventory/Pages/**/*.tsx" + } + ], + "inertia.defaultExtension": ".tsx", + "inertia.pathSeparators": ["/", "."] +} diff --git a/package.json b/package.json index b6dbde0..7a0edc2 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,25 @@ "default": "resources/js/Pages/**/*.vue", "description": "A glob pattern to match the Vue components that you use as pages in your Inertia application. The root folder of your components is inferred from the glob pattern." }, + "inertia.pageResolvers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "description": "The prefix used in component names (e.g., 'Shipping', 'Accounting')" + }, + "pattern": { + "type": "string", + "description": "The glob pattern to match components for this resolver (e.g., 'Modules/Shipping/Pages/**/*.tsx')" + } + }, + "required": ["prefix", "pattern"] + }, + "default": [], + "description": "Array of page resolvers, each with a prefix and corresponding glob pattern. This allows support for modular applications with multiple page directories." + }, "inertia.pathSeparators": { "type": "array", "items": { diff --git a/src/editor/autocompletion.provider.ts b/src/editor/autocompletion.provider.ts index 9619521..f102d14 100644 --- a/src/editor/autocompletion.provider.ts +++ b/src/editor/autocompletion.provider.ts @@ -1,4 +1,9 @@ -import { pathDiff, unglob } from "@/helpers"; +import { + type PageResolver, + getAllPagePatterns, + pathDiff, + unglob, +} from "@/helpers"; import { CompletionItem, CompletionItemKind, @@ -38,38 +43,58 @@ export class AutocompletionProvider implements CompletionItemProvider { return []; } - const pagesGlob: string | undefined = workspace - .getConfiguration("inertia") - .get("pages"); - + const config = workspace.getConfiguration("inertia"); const firstPathSeparator: string | undefined = - workspace.getConfiguration("inertia").get("pathSeparators", ["/"])?.[0] ?? - "/"; + config.get("pathSeparators", ["/"])?.[0] ?? "/"; - if (!pagesGlob) { - return undefined; + // Get all page patterns (both legacy and new resolvers) + const pagePatterns = getAllPagePatterns(workspace); + + if (pagePatterns.length === 0) { + // Fall back to legacy single pattern if no patterns found + const pagesGlob: string | undefined = config.get("pages"); + if (!pagesGlob) { + return undefined; + } + pagePatterns.push({ pattern: pagesGlob }); } - return workspace - .findFiles({ - base: workspaceURI.toString(), - baseUri: workspaceURI, - pattern: pagesGlob, - }) - .then((files) => { - console.log(files); - return files.map((uri) => { - const base = Uri.joinPath(workspaceURI, unglob(pagesGlob)); - return new CompletionItem( - { - label: pathDiff(base, uri) - .replace(/\.[^/.]+$/, "") - .replaceAll("/", firstPathSeparator), - description: "Inertia.js", - }, - CompletionItemKind.Value, - ); - }); - }); + // Create completion items for all patterns + const completionPromises = pagePatterns.map(({ pattern, prefix }) => + workspace + .findFiles({ + base: workspaceURI.toString(), + baseUri: workspaceURI, + pattern: pattern, + }) + .then((files: Uri[]) => { + return files.map((uri) => { + const base = Uri.joinPath(workspaceURI, unglob(pattern)); + const componentPath = pathDiff(base, uri) + .replace(/\.[^/.]+$/, "") + .replaceAll("/", firstPathSeparator); + + // Add prefix if this pattern has one + const finalPath = prefix + ? `${prefix}:${componentPath}` + : componentPath; + + return new CompletionItem( + { + label: finalPath, + description: prefix + ? `Inertia.js (${prefix} Module)` + : "Inertia.js", + }, + CompletionItemKind.Value, + ); + }); + }), + ); + + return Promise.all(completionPromises).then((completionArrays) => { + // Flatten the arrays of completion items + return completionArrays.flat(); + }); } } diff --git a/src/editor/component-link.provider.ts b/src/editor/component-link.provider.ts index 0e3d134..fe51a4f 100644 --- a/src/editor/component-link.provider.ts +++ b/src/editor/component-link.provider.ts @@ -1,4 +1,10 @@ -import { locateInDocument, unglob } from "@/helpers"; +import { + type PageResolver, + getAllPagePatterns, + locateInDocument, + resolveComponentWithPrefix, + unglob, +} from "@/helpers"; import { type DocumentLink, type DocumentLinkProvider, @@ -72,14 +78,53 @@ export class ComponentLinkProvider implements DocumentLinkProvider { return undefined; } - const pages: string | undefined = workspace - .getConfiguration("inertia") - .get("pages"); + const componentName = document.getText(link.range); + const config = workspace.getConfiguration("inertia"); - // Handle deprecated setting - const pagesFolder: string | undefined = workspace - .getConfiguration("inertia") - .get("pagesFolder"); + // Get page resolvers for prefix-based resolution + const pageResolvers: PageResolver[] = config.get("pageResolvers", []); + const prefixResolution = resolveComponentWithPrefix( + componentName, + pageResolvers, + ); + + if (prefixResolution) { + // Handle prefix-based component resolution + const { resolver, componentPath } = prefixResolution; + + return workspace + .findFiles({ + base: workspaceURI.toString(), + baseUri: workspaceURI, + pattern: resolver.pattern, + }) + .then((files: Uri[]) => { + const normalizedPath = this.normalizeComponentPath(componentPath); + const file = files.find((file: Uri) => { + return file.path.startsWith( + Uri.joinPath( + workspaceURI, + unglob(resolver.pattern), + normalizedPath, + ).path, + ); + }); + + link.target = + file ?? + Uri.joinPath( + workspaceURI, + unglob(resolver.pattern), + normalizedPath + config.get("defaultExtension", ".vue"), + ); + + return link; + }); + } + + // Fall back to legacy single pattern resolution + const pages: string | undefined = config.get("pages"); + const pagesFolder: string | undefined = config.get("pagesFolder"); if (pages === undefined || pagesFolder === undefined) { return undefined; @@ -92,11 +137,9 @@ export class ComponentLinkProvider implements DocumentLinkProvider { baseUri: workspaceURI, pattern: pages, }) - .then((files) => { - const path = document.getText(link.range); - + .then((files: Uri[]) => { const file = files.find((file: Uri) => { - const normalized = this.normalizeComponentPath(path); + const normalized = this.normalizeComponentPath(componentName); return file.path.startsWith( Uri.joinPath(workspaceURI, unglob(pages), normalized).path, ); @@ -107,10 +150,8 @@ export class ComponentLinkProvider implements DocumentLinkProvider { Uri.joinPath( workspaceURI, unglob(pages), - this.normalizeComponentPath(path) + - workspace - .getConfiguration("inertia") - .get("defaultExtension", ".vue"), + this.normalizeComponentPath(componentName) + + config.get("defaultExtension", ".vue"), ); return link; @@ -123,7 +164,7 @@ export class ComponentLinkProvider implements DocumentLinkProvider { .get("pathSeparators", ["/"]); return component.replaceAll( - new RegExp(`[${pathSeparators.join("")}]`, "g"), + new RegExp(`[${(pathSeparators || ["/"]).join("")}]`, "g"), "/", ); } diff --git a/src/helpers.ts b/src/helpers.ts index 0d872b0..07907b3 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,13 @@ import { Range, type TextDocument, type Uri } from "vscode"; +/** + * Interface for page resolver configuration + */ +export interface PageResolver { + prefix: string; + pattern: string; +} + /** * Computes the difference between two paths */ @@ -58,3 +66,53 @@ export const locateInDocument = ( return results; }; + +/** + * Resolves a component name with prefix to its actual file path + */ +export const resolveComponentWithPrefix = ( + componentName: string, + pageResolvers: PageResolver[], +): { resolver: PageResolver; componentPath: string } | null => { + // Check if component has a prefix (format: "Prefix:ComponentPath") + const prefixMatch = componentName.match(/^([^:]+):(.+)$/); + + if (!prefixMatch) { + return null; + } + + const [, prefix, componentPath] = prefixMatch; + const resolver = pageResolvers.find((r) => r.prefix === prefix); + + if (!resolver) { + return null; + } + + return { resolver, componentPath }; +}; + +/** + * Gets all configured page patterns (both legacy and new resolver-based) + */ +export const getAllPagePatterns = (workspace: { + getConfiguration: (section: string) => { + get: (key: string, defaultValue?: unknown) => unknown; + }; +}): { pattern: string; prefix?: string }[] => { + const config = workspace.getConfiguration("inertia"); + const patterns: { pattern: string; prefix?: string }[] = []; + + // Add legacy pages pattern if configured + const legacyPages = config.get("pages") as string | undefined; + if (legacyPages) { + patterns.push({ pattern: legacyPages }); + } + + // Add new page resolvers + const pageResolvers = config.get("pageResolvers", []) as PageResolver[]; + for (const resolver of pageResolvers) { + patterns.push({ pattern: resolver.pattern, prefix: resolver.prefix }); + } + + return patterns; +}; diff --git a/test/fixtures/.vscode/settings.json b/test/fixtures/.vscode/settings.json new file mode 100644 index 0000000..b3966ce --- /dev/null +++ b/test/fixtures/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "inertia.pageResolvers": [ + { + "prefix": "Shipping", + "pattern": "Modules/Shipping/Pages/**/*.tsx" + }, + { + "prefix": "Accounting", + "pattern": "Modules/Accounting/Pages/**/*.tsx" + }, + { + "prefix": "Inventory", + "pattern": "Modules/Inventory/Pages/**/*.tsx" + } + ], + "inertia.defaultExtension": ".tsx", + "inertia.pathSeparators": ["/", "."] +} diff --git a/test/fixtures/Modules/Accounting/Pages/Reports/Balance.tsx b/test/fixtures/Modules/Accounting/Pages/Reports/Balance.tsx new file mode 100644 index 0000000..913c652 --- /dev/null +++ b/test/fixtures/Modules/Accounting/Pages/Reports/Balance.tsx @@ -0,0 +1,14 @@ +import { Head } from "@inertiajs/react"; +import React from "react"; + +export default function Balance() { + return ( + <> +
+
Accounting balance sheet report.
++ > + ); +} diff --git a/test/fixtures/Modules/Help.tsx b/test/fixtures/Modules/Help.tsx new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/Modules/Inventory/Pages/Items/List.tsx b/test/fixtures/Modules/Inventory/Pages/Items/List.tsx new file mode 100644 index 0000000..280fca2 --- /dev/null +++ b/test/fixtures/Modules/Inventory/Pages/Items/List.tsx @@ -0,0 +1,14 @@ +import { Head } from "@inertiajs/react"; +import React from "react"; + +export default function List() { + return ( + <> +
+
List of all inventory items.
++ > + ); +} diff --git a/test/fixtures/Modules/Shipping/Pages/Orders/Index.tsx b/test/fixtures/Modules/Shipping/Pages/Orders/Index.tsx new file mode 100644 index 0000000..87c01c4 --- /dev/null +++ b/test/fixtures/Modules/Shipping/Pages/Orders/Index.tsx @@ -0,0 +1,14 @@ +import { Head } from "@inertiajs/react"; +import React from "react"; + +export default function Index() { + return ( + <> +
+
List of all shipping orders.
++ > + ); +} diff --git a/test/fixtures/Modules/Some/Page.tsx b/test/fixtures/Modules/Some/Page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/routes.php b/test/fixtures/routes.php index adbfcd5..fd3bc3d 100644 --- a/test/fixtures/routes.php +++ b/test/fixtures/routes.php @@ -6,5 +6,9 @@ inertia('Help'); +// New modular examples with prefixes +Route::get('/shipping/orders', fn () => inertia('Shipping:Orders/Index'))->name('shipping.orders'); +Route::get('/accounting/reports', fn () => inertia('Accounting:Reports/Balance'))->name('accounting.reports'); +Route::get('/inventory/items', fn () => inertia('Inventory:Items/List'))->name('inventory.items'); Route::get('/', fn () => inertia('Some/Page'))->name('dashboard');