diff --git a/docs/implementation/media/plugin-detail-page.png b/docs/implementation/media/plugin-detail-page.png new file mode 100644 index 00000000..5ebc9a29 Binary files /dev/null and b/docs/implementation/media/plugin-detail-page.png differ diff --git a/packages/plugin-kit/README.md b/packages/plugin-kit/README.md index 3cc559e0..ab5b029f 100644 --- a/packages/plugin-kit/README.md +++ b/packages/plugin-kit/README.md @@ -93,6 +93,21 @@ export const plugin = { Every other folder and file under `commera/` is yours: components, composables, sub-pages. Only an `index.vue` at a placement path is built as a plugin. +### Detail pages + +Put a `[...id].vue` next to a page's `index.vue` to give it a detail view: + +``` +pages/orders/index.vue /commera/plugins//orders +pages/orders/[...id].vue /commera/plugins//orders/SAL-ORD-0001 +``` + +Everything after the page name reaches the detail as one prop named after the file, slashes included, so +`orders/INV/2026/001` gives `id = 'INV/2026/001'`. Declare it with `defineProps({ id: String })`. The detail +has no `plugin` block: it shares the page's sidebar row, `requires` and `condition`. Open it with +`navigate(\`orders/${name}\`)` and set its header with `usePage()`. A page holds one `[...name].vue`; +`[id].vue` (one URL segment) is reserved and is not built. + ## App logo Put an optional `commera/icon.svg` next to `pages/`. Set it once: it is the icon of everything your app diff --git a/packages/plugin-kit/test/kit.test.js b/packages/plugin-kit/test/kit.test.js index 5dd6f6c6..f766d280 100644 --- a/packages/plugin-kit/test/kit.test.js +++ b/packages/plugin-kit/test/kit.test.js @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { after, describe, test } from 'node:test'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import commera from '../vite.js'; +import commera, { discoverPlugins } from '../vite.js'; // The kit installs nothing of its own; the dashboard's node_modules carries the vite and vue it builds with. const here = dirname(fileURLToPath(import.meta.url)); @@ -463,6 +463,140 @@ describe('guards', () => { }); }); +describe('detail pages', () => { + const detail = `\n\n`; + + // The built page runs on the real vue; only the host's usePlugin() is stood in for, so `path` can be set. + async function renderAt(outDir, entry, paths) { + writeFileSync(join(outDir, 'package.json'), '{"type":"module"}'); + const admin = join(outDir, '../../node_modules/@commera/admin'); + mkdirSync(admin, { recursive: true }); + writeFileSync( + join(admin, 'package.json'), + '{"name":"@commera/admin","type":"module","exports":"./index.js"}', + ); + writeFileSync( + join(admin, 'index.js'), + "import { ref } from 'vue'\nexport const path = ref('')\nexport const usePlugin = () => ({ path })\n", + ); + const { path } = await import(pathToFileURL(join(admin, 'index.js')).href); + const { createSSRApp } = await import( + pathToFileURL(join(dashboardModules, 'vue/index.mjs')).href + ); + const { renderToString } = await import( + pathToFileURL(join(dashboardModules, 'vue/server-renderer/index.mjs')) + .href + ); + const { default: Page } = await import( + pathToFileURL(join(outDir, entry)).href + ); + const html = []; + for (const value of paths) { + path.value = value; + html.push(await renderToString(createSSRApp(Page))); + } + return html; + } + + test('[...id].vue shows for any path under the page and gets all of it as its prop', async () => { + const { manifest, outDir } = await buildApp({ + 'pages/jobs/index.vue': vue( + `{ label: 'Print jobs', icon: 'printer' }`, + '', + ), + 'pages/jobs/[...id].vue': detail, + }); + assert.deepEqual( + manifest.entries.map((entry) => [entry.name, entry.module]), + [['jobs', 'pages/jobs.js']], + ); + assert.deepEqual( + await renderAt(outDir, 'pages/jobs.js', ['', 'JOB-1', 'INV/2026/001']), + [ + '
list
', + '
JOB-1
', + '
INV/2026/001
', + ], + ); + }); + + test('the prop is named after the file', async () => { + const { outDir } = await buildApp({ + 'pages/jobs/index.vue': page, + 'pages/jobs/[...jobName].vue': `\n\n`, + }); + assert.deepEqual(await renderAt(outDir, 'pages/jobs.js', ['JOB-7']), [ + '
JOB-7
', + ]); + }); + + test('[id].vue warns and is not built', async () => { + const files = { + 'pages/jobs/index.vue': page, + 'pages/jobs/[id].vue': + '', + }; + const compiler = await import( + pathToFileURL(join(dashboardModules, 'vue/compiler-sfc/index.mjs')).href + ); + const warnings = []; + const { entries } = discoverPlugins(join(makeApp(files), 'commera'), { + app: APP, + compiler, + warn: (message) => warnings.push(message), + }); + assert.equal(entries[0].detail, undefined); + assert.match( + warnings[0], + /pages\/jobs\/\[id\]\.vue is reserved[\s\S]*\[\.\.\.id\]\.vue/, + ); + const { read } = await buildApp(files); + assert.doesNotMatch(read('pages/jobs.js'), /single/); + }); + + test('outside a page folder fails', async () => { + await buildFails( + { + 'pages/jobs/index.vue': page, + 'order/cards/status/[...id].vue': detail, + }, + /order\/cards\/status\/\[\.\.\.id\]\.vue: only a page folder/, + ); + }); + + test('without the page index.vue fails', async () => { + await buildFails( + { 'pages/jobs/[...id].vue': detail }, + /pages\/jobs\/\[\.\.\.id\]\.vue: add pages\/jobs\/index\.vue/, + ); + }); + + test('two in one page fails', async () => { + await buildFails( + { + 'pages/jobs/index.vue': page, + 'pages/jobs/[...id].vue': detail, + 'pages/jobs/[...name].vue': detail, + }, + /pages\/jobs\/\[\.\.\.name\]\.vue: pages\/jobs already has pages\/jobs\/\[\.\.\.id\]\.vue/, + ); + }); + + test('a prop name that is not camelCase fails', async () => { + await buildFails( + { 'pages/jobs/index.vue': page, 'pages/jobs/[...job-id].vue': detail }, + /'job-id' must be a camelCase prop name/, + ); + }); + + test('a plugin block in it fails', async () => { + await buildFails( + { 'pages/jobs/index.vue': page, 'pages/jobs/[...id].vue': page }, + /pages\/jobs\/\[\.\.\.id\]\.vue declares a plugin block, but only a placement's index\.vue may/, + ); + }); +}); + describe('a clean build', () => { test('the manifest covers every placement and the shipped JS keeps no dotted path', async () => { const card = (label) => diff --git a/packages/plugin-kit/vite.js b/packages/plugin-kit/vite.js index a0eddeed..623d36aa 100644 --- a/packages/plugin-kit/vite.js +++ b/packages/plugin-kit/vite.js @@ -25,6 +25,10 @@ const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/; const DOTTED_PATH = /^[A-Za-z_]\w*(\.[A-Za-z_]\w*)+$/; const EMPTY_ENTRY = '\0commera-empty-entry'; const EMPTY_ENTRY_PATH = '__commera_empty_entry__'; +const PAGE_ENTRY = '\0commera-page:'; +const PAGE_ENTRY_PATH = '__commera_page__/'; +const DYNAMIC_FILE = /^\[(\.\.\.)?([^\]]*)\]\.vue$/; +const PARAM = /^[a-z][A-Za-z0-9]{0,39}$/; const SKIPPED_DIRS = new Set(['node_modules', 'dist']); const ICON_FILE = 'icon.svg'; const ICON_LIMIT = 20 * 1024; @@ -343,11 +347,25 @@ export function discoverPlugins( const errors = []; const entries = []; const seen = new Map(); + const details = new Map(); for (const file of walk(sourceDir).sort()) { const display = toPosix(relative(sourceDir, file)); const isIndex = basename(file) === 'index.vue'; const folder = toPosix(dirname(display)); const placement = isIndex ? matchPlacement(folder) : null; + const dynamic = basename(file).match(DYNAMIC_FILE); + if (dynamic) { + const [, rest, param] = dynamic; + const problem = getDetailProblem(folder, rest, param, details); + if (problem === 'reserved') { + warn( + `${display} is reserved for a later Commera; it was not built. Name it [...${param}].vue to get the rest of the URL`, + ); + continue; + } + if (problem) errors.push(`${display}: ${problem}`); + else details.set(folder, { file, param, display }); + } const block = readPluginBlock(compiler, file, display); if (!placement) { @@ -406,9 +424,46 @@ export function discoverPlugins( plugin, }); } + for (const [folder, detail] of details) { + const entry = entries.find( + (entry) => entry.place === 'pages' && `pages/${entry.name}` === folder, + ); + if (entry) entry.detail = detail; + else if (!existsSync(join(sourceDir, folder, 'index.vue'))) + errors.push( + `${detail.display}: add ${folder}/index.vue; a detail page needs its list page`, + ); + } return { entries, errors }; } +function getDetailProblem(folder, rest, param, details) { + if (matchPlacement(folder)?.place !== 'pages') + return 'only a page folder (pages//) may hold a [...param].vue'; + if (!rest) return 'reserved'; + if (!PARAM.test(param)) + return `'${param}' must be a camelCase prop name, such as [...id].vue`; + if (details.has(folder)) + return `${folder} already has ${details.get(folder).display}`; +} + +// The page stays mounted while its URL changes, so the detail is keyed by its id to load each record fresh. +function pageEntryCode(entry) { + const { file, param } = entry.detail; + return [ + "import { defineComponent, h } from 'vue';", + "import { usePlugin } from '@commera/admin';", + `import Page from ${JSON.stringify(entry.file)};`, + `import Detail from ${JSON.stringify(file)};`, + 'export default defineComponent({', + '\tsetup() {', + '\t\tconst { path } = usePlugin();', + `\t\treturn () => (path.value ? h(Detail, { key: path.value, ${param}: path.value }) : h(Page));`, + '\t},', + '});', + ].join('\n'); +} + function readHostFile(hostDir, name) { const path = join(hostDir, name); return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null; @@ -431,7 +486,7 @@ function stripPlainScript(compiler, code, id) { return code.slice(0, start) + code.slice(end); } -function guard({ hostDir, compiler, pluginFiles }) { +function guard({ hostDir, compiler, pluginFiles, pageEntries }) { const knownClasses = readHostFile(hostDir, 'classes.json'); const sharedExports = readHostFile(hostDir, 'shared-exports.json'); const knownClassSet = knownClasses ? new Set(knownClasses) : null; @@ -447,6 +502,8 @@ function guard({ hostDir, compiler, pluginFiles }) { }, resolveId(source) { if (source.endsWith(EMPTY_ENTRY_PATH)) return EMPTY_ENTRY; + const pageEntry = source.split(PAGE_ENTRY_PATH)[1]; + if (pageEntries.has(pageEntry)) return `${PAGE_ENTRY}${pageEntry}`; const sharesRoot = SHARED_ROOTS.some((name) => source.startsWith(`${name}/`), ); @@ -460,6 +517,8 @@ function guard({ hostDir, compiler, pluginFiles }) { }, load(id) { if (id === EMPTY_ENTRY) return 'export {};'; + if (id.startsWith(PAGE_ENTRY)) + return pageEntries.get(id.slice(PAGE_ENTRY.length)); }, transform(code, id) { if (id.includes('/node_modules/')) return; @@ -662,14 +721,24 @@ export default async function commeraPlugin({ .join('\n')}\n`, ); } + const pageEntries = new Map( + entries + .filter((entry) => entry.detail) + .map((entry) => [entry.entryName, pageEntryCode(entry)]), + ); const moduleEntries = Object.fromEntries( entries .filter((entry) => entry.entryName) - .map((entry) => [entry.entryName, entry.file]), + .map((entry) => [ + entry.entryName, + pageEntries.has(entry.entryName) + ? `${PAGE_ENTRY_PATH}${entry.entryName}` + : entry.file, + ]), ); const pluginFiles = new Set(entries.map((entry) => entry.file)); return [ - guard({ hostDir: resolvedHostDir, compiler, pluginFiles }), + guard({ hostDir: resolvedHostDir, compiler, pluginFiles, pageEntries }), vue(), finish({ app, entries, icon: appIcon.icon }), {