feat: +pages for programmatically defining pages (closes #1691) - #3356
Conversation
Allow defining pages from code, without a +Page/+config file on the
filesystem:
// pages/+config.js
import ProductPage from './ProductPage'
export default {
pages: [
{ route: '/product/@id', Page: ProductPage },
],
}
Each entry is resolved at config-time into a synthetic +config.js at a
namespaced locationId (`<definingLocationId>/(programmatic)/<slug>`), so
it flows through the existing config resolution unchanged: config
inheritance (renderer, title, ...), pointer-import resolution of `Page`,
routing, serialization, SSR and prerendering all work as for filesystem
pages.
This is the config-time half of #1691 (the function/transformer form is
not included yet).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Move addProgrammaticPages() (and its slug/locationId helpers) out of getPlusFilesByLocationId.ts into a standalone file. getPlusFilesByLocationId.ts now exports getPlusFileFromConfigFile() and the PlusFileConfig type for reuse. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
|
Small thing, the description says it exports PageConfigEntry, but the code |
|
Good catch — aligned the PR description with the code. The exported type is Generated by Claude Code |
The `id` field is superfluous: the slug (used only to derive the synthetic locationId) is derived from the `route` string, falling back to the array index for non-string routes (e.g. Route Functions). Duplicate slugs are still asserted, advising a unique `route`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Replace the synthetic-plusFile approach (which fabricated a filesystem locationId per entry under a `(programmatic)` directory and injected it into plusFilesByLocationId) with a proper model that decouples a page's *identity* from a filesystem *location*: - Each config.pages entry becomes a first-class PageConfigBuildTime built in getProgrammaticPageConfigs(). - Config inheritance is anchored at the +config.js that defines config.pages, so the page inherits its surrounding config (renderer via `extends`, title, ssr, …) — exactly what a page at that location would. - The entry's own values (route, Page, …) take highest precedence (like a filesystem page's own + files) and attach to the page only, so they don't leak to sibling pages via inheritance. - pageId is an identity (anchor location + per-location index), not a fabricated path; plusFilesByLocationId stays purely filesystem. Deletes addProgrammaticPages.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
The first cut applied effects before layering the entry's own config values, so an entry that set/overrode a config with a meta.effect wouldn't trigger it (effects only saw the inherited values). Fix by routing programmatic pages through the same pipeline as filesystem pages: extract buildPageConfig() and make the entry the page's most-specific source (a synthetic child location of the defining +config.js, never added to plusFilesByLocationId). Now resolveConfigValueSources + applyEffects + sortConfigValueSources + getComputed all see the entry exactly like a filesystem page's own + files — so effects, computed values and precedence are identical and correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
…on config getProgrammaticPageConfigs() built the entry's throwaway PlusFile with the defining file's isExtensionConfig. When config.pages is defined by an extension (isExtensionConfig=true), resolveConfigValueSources() → dedupeExtensions() does assert(getExtensionName(plusFile)), which crashes because the synthetic entry has no `name`. The entry is a concrete page definition, not a dedupe-able extension config, so build it with isExtensionConfig=false (also the correct precedence). Unblocks extension-defined pages (e.g. vike-authjs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
…on unknown configs - Discover config.pages via getConfVal(), so it works both as config.pages in +config.js and as a +pages.js value file (verified end-to-end). - Forbid Route Functions for now: ConfigPageEntry.route is typed as `string` and asserted at runtime (also rejects a missing/non-string route). - Warn on unknown configs set by a page entry (reuses isUnknownConfig), like Vike does for + files. - Remove `| ImportStringList` from the `pages` type (doesn't apply here). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
isGlobalLocation() determined "does a config here apply to every page?" only from filesystem page-locations. Programmatic pages (config.pages) aren't in plusFilesByLocationId, so an app with only programmatic pages made every location vacuously global. Count locations defining config.pages too (their programmatic pages are anchored there), so a config that doesn't cover them isn't considered global. Kept separate from isDefiningPage() so getPageConfigsBuildTime() doesn't build a bogus filesystem page at the anchor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
…d extends in entries - Forbid setting a global config (e.g. +onBeforeRoute) on a single page entry; it was silently dropped. (Checks `global === true` so conditionally-global configs like +prerender remain allowed per-page.) - Validate the entry's route via assertRouteString() (leading slash, non-empty, …) instead of only asserting it's a string. - Forbid `extends` in an entry (it was a silent no-op). - Add a perf note: createPageConfig() re-resolves the inherited config per entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Make the comments explaining the programmatic-pages build both more succinct and clearer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
…fig.pages)
A `config.pages` entry can now set `+route` to a Route Function, as long as
it's a pointer import so that Vike can load it at runtime:
```js
// +config.js
import route from './route' with { type: 'vike:pointer' }
export default {
pages: [{ route, Page }]
}
```
Route Strings keep being validated at config-time; a Route Function (pointer
import) is validated at runtime, just like a Route Function defined via a
`+route.js` file. An inlined function still errors, with a message pointing to
the pointer-import syntax.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
Split the single generic check into two bespoke assertUsage() calls: - An inlined Route Function points the user to the pointer-import syntax. - A non-string +route is told to use a Route String. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z
config.pages (#1691)+pages programmatically define pages (fix #1691)
+pages programmatically define pages (fix #1691)+pages programmatically define pages (closes #1691)
+pages programmatically define pages (closes #1691)+pages for programmatically defining pages (closes #1691)
What
Implements the config-time half of #1691: define pages from code, without a
+Page/+configfile on the filesystem.This unblocks the "Single Route File" request (#341), is the foundation for extension-defined pages (e.g.
vike-authjs/vike-better-auth), and is the registration primitive needed for the page-variant use cases in #1525 (OG images,.mdfor LLMs).Note
Per discussion, this is only the list form. The function/transformer form (
pages: (pages) => [...]) is intentionally left for a follow-up. Runtime/post-build programmatic pages are also out of scope.How
Each
pagesentry is turned, at config-time, into a synthetic+config.jsat a namespacedlocationId—{definingLocationId}/(programmatic)/{slug}— so it flows through the existing config-resolution pipeline unchanged, with no duplicated logic:{ route, Page, … }is structurally a+config.jsdefault export, so it reusesgetPlusFileFromConfigFile(). This meansPageis a top-level pointer import and gets resolved + serialized into a real runtimeimportfor free.extends,title, etc.).(programmatic)marker is a parenthesis route-group (already a first-class Vike concept): it's stripped from the Filesystem-Routing fallback and guarantees a synthetic page can never collide with — nor become the inheritance parent of — a real page.slugis derived fromid(optional) or theroutestring; duplicates are asserted with a clear message.Touchpoints:
types/Config.ts—pages?: ConfigPageEntry[]+ exportedConfigPageEntrytype; registered as a built-in global config name.metaBuiltIn.ts—pages: { env: { config: true }, global: true, cumulative: true }(config-time only; never shipped to runtime; definable globally or by extensions).getPlusFilesByLocationId.ts/addProgrammaticPages.ts—addProgrammaticPages()runs right after the filesystem crawl.Verification
tsc --noEmitclean forpackages/vikeand first-party playground files.vitest --project unit).test/playground(+ e2e test wired intotestRun.ts) and verified end-to-end, browser-free:dist/.../programmatic.htmlwith the renderedProgrammatic Pageheading, inherits the page title (title: 'Big Playground'), and thePagecomponent is shipped in a client chunk (hydrates via the client-routing entry).pageId === /pages/(programmatic)/programmatic.GET /programmatic→ 200 with rendered + inherited content.Follow-ups (not in this PR)
pages: (pages) => [...](the New hookonRenderContent(): generate non-HTML files with arbitrary content #1525 variant story).https://vike.dev/pages.Closes part of #1691.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FMtgpc4sFVWUa7PPo1U92z