Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/implementation/media/plugin-detail-page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions packages/plugin-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app>/orders
pages/orders/[...id].vue /commera/plugins/<app>/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
Expand Down
136 changes: 135 additions & 1 deletion packages/plugin-kit/test/kit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -463,6 +463,140 @@ describe('guards', () => {
});
});

describe('detail pages', () => {
const detail = `<script setup>\ndefineProps({ id: String })\n</script>\n<template><div class="p-2">{{ id }}</div></template>\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' }`,
'<template><div class="p-2">list</div></template>',
),
'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']),
[
'<div class="p-2">list</div>',
'<div class="p-2">JOB-1</div>',
'<div class="p-2">INV/2026/001</div>',
],
);
});

test('the prop is named after the file', async () => {
const { outDir } = await buildApp({
'pages/jobs/index.vue': page,
'pages/jobs/[...jobName].vue': `<script setup>\ndefineProps({ jobName: String })\n</script>\n<template><div class="p-2">{{ jobName }}</div></template>\n`,
});
assert.deepEqual(await renderAt(outDir, 'pages/jobs.js', ['JOB-7']), [
'<div class="p-2">JOB-7</div>',
]);
});

test('[id].vue warns and is not built', async () => {
const files = {
'pages/jobs/index.vue': page,
'pages/jobs/[id].vue':
'<template><div class="p-2">single</div></template>',
};
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) =>
Expand Down
75 changes: 72 additions & 3 deletions packages/plugin-kit/vite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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/<name>/) 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;
Expand All @@ -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;
Expand All @@ -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}/`),
);
Expand All @@ -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;
Expand Down Expand Up @@ -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 }),
{
Expand Down
Loading