@wix/origami-site 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-site",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Runs an exported Wix origami site: file-based routing, Wix dynamic pages, popups and build checks. Use with @wix/origami-site-astro or @wix/origami-site-express.",
5
5
  "type": "module",
6
6
  "files": [
@@ -48,7 +48,7 @@
48
48
  "@wix/origami-types": "1.4.0",
49
49
  "@wix/builder-error-pages": "^1.1.6",
50
50
  "@wix/builder-piler-components": "^1.6.7",
51
- "@wix/origami-transforms": "0.1.10",
51
+ "@wix/origami-transforms": "0.1.11",
52
52
  "@babel/parser": "^7.25.0",
53
53
  "@wix/system-core-components": "^1.3.10"
54
54
  },
@@ -91,5 +91,5 @@
91
91
  "optional": true
92
92
  }
93
93
  },
94
- "falconPackageHash": "fee6097e759f022daaec303620c3fb7c3e01ac7ae1c727ce992a7f88"
94
+ "falconPackageHash": "0c04ba80f9a2e61581387449cc08422f3616a50308d1d5691f31751f"
95
95
  }
@@ -4,7 +4,8 @@ const resolvedId = `\0${publicId}`
4
4
  /** @param {import('@wix/origami-types').SiteRouteTable} table */
5
5
  function moduleSource(table) {
6
6
  // itemPath is relative to src/ and already includes the pages/ prefix.
7
- const entries = [...table.pages, table.notFound].map(page => {
7
+ // Role pages too: the router decides on the server which one the browser loads.
8
+ const entries = [...table.pages, ...table.routers.flatMap(router => Object.values(router.rolePages)), table.notFound].map(page => {
8
9
  const entry = page.itemPath
9
10
  ? `/src/${page.itemPath}/_generated/index.jsx`
10
11
  : '@wix/origami-site/default-error-404'
@@ -9,7 +9,7 @@ export type SiteOptions = {
9
9
  mode: 'dev' | 'prod'
10
10
  table?: SiteRouteTable // prod: the table from the build. dev: omitted, the core scans.
11
11
  wix: { clientId: string; siteId: string } // from VITE_WIX_CLIENT_ID / VITE_WIX_METASITE_ID
12
- tokens?: TokenProvider // default: rejects until router support (stage 2)
12
+ tokens?: TokenProvider // default: builderAuthTokenProvider(wix)
13
13
  access?: AccessSource // default: noAccessRules (4)
14
14
  accessHandlers?: AccessHandler[] // default: []
15
15
  fetch?: typeof fetch // tests inject a fake
@@ -70,8 +70,9 @@ export interface AccessHandler {
70
70
  }
71
71
 
72
72
 
73
- export { SiteTreeError } from './requests/errors.js'
74
- export type { RouterServiceError } from './requests/errors.js'
73
+ export { SiteTreeError, RouterServiceError } from './requests/errors.js'
74
+ /** The default TokenProvider: the visitor's builder-auth cookie when fresh, else a one-off anonymous token. Stores nothing. */
75
+ export function builderAuthTokenProvider(options: { clientId: string; siteId: string; wixApiBaseUrl?: string; fetch?: typeof fetch; timeoutMs?: number }): TokenProvider
75
76
  export function createSite(options: SiteOptions): Promise<OrigamiSite>
76
77
  export function readSiteTree(root: string): Promise<SiteRouteTable>
77
78
  export function runInRequestContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T>
@@ -1,6 +1,7 @@
1
1
  export { readSiteTree } from './site-tree/readSiteTree.js'
2
2
  export { writeBuiltTable, readBuiltTable } from './site-tree/builtTable.js'
3
- export { SiteTreeError } from './requests/errors.js'
3
+ export { SiteTreeError, RouterServiceError } from './requests/errors.js'
4
+ export { builderAuthTokenProvider } from './tokens/builderAuthTokenProvider.js'
4
5
  export { createSite, noAccessRules } from './requests/createSite.js'
5
6
  export { runInRequestContext, getRequestContext, getAccessToken } from './requests/context.js'
6
7
  export { serializePageData } from './requests/pageData.js'
@@ -1,32 +1,51 @@
1
1
  import { createSiteTree } from '../site-tree/createSiteTree.js'
2
2
  import { readSiteTree } from '../site-tree/readSiteTree.js'
3
- import { SiteTreeError } from './errors.js'
3
+ import { callRouter } from '../router/index.js'
4
+ import { pickRolePage } from '../router/pickRolePage.js'
5
+ import { builderAuthTokenProvider } from '../tokens/builderAuthTokenProvider.js'
6
+ import { RouterServiceError, SiteTreeError } from './errors.js'
4
7
  import { normalizeRequestPath } from './urlPath.js'
5
8
  import { canBePagePath } from './canBePagePath.js'
6
- import { runInRequestContext } from './context.js'
9
+ import { getAccessToken, runInRequestContext } from './context.js'
7
10
  import { errorPage } from './errorPage.js'
8
11
 
9
12
  /** @type {import('../index.js').AccessSource} */
10
13
  export const noAccessRules = { async load() { return new Map() } }
11
- const unavailableTokens = { async getAccessToken() { throw new Error('Default access tokens are not available until router support.') } }
12
14
  const defaultLogger = {
13
15
  warn: (...args) => console.warn('[origami]', ...args),
14
16
  error: (...args) => console.error('[origami]', ...args),
15
17
  }
18
+ /** @param {import('@wix/origami-types').SiteRouteTable} table */
19
+ const rolePagesOf = table => table.routers.flatMap(router => Object.values(router.rolePages))
16
20
 
17
21
  /** @param {import('../index.js').SiteOptions} options @returns {Promise<import('../index.js').OrigamiSite>} */
18
22
  export async function createSite(options) {
19
- const { root, mode, access = noAccessRules, accessHandlers = [], tokens = unavailableTokens, logger = defaultLogger } = options
23
+ const {
24
+ root, mode, wix, access = noAccessRules, accessHandlers = [], logger = defaultLogger, fetch = globalThis.fetch,
25
+ wixApiBaseUrl = 'https://www.wixapis.com', routerTimeoutMs = 5000,
26
+ } = options
27
+ const tokens = options.tokens ?? builderAuthTokenProvider({ clientId: wix.clientId, siteId: wix.siteId, wixApiBaseUrl, fetch })
28
+ /** Fail closed: a rule with no handler stops the site instead of serving the page open.
29
+ * @param {{ itemPath: string, access: import('@wix/origami-types').AccessRule[] }} route */
30
+ const handlerFor = (route, rule) => {
31
+ const handler = accessHandlers.find(candidate => candidate.type === rule.type)
32
+ if (!handler) throw new SiteTreeError('UNSUPPORTED_ACCESS_RULE', [route.itemPath], `No access handler for rule type "${rule.type}".`)
33
+ return handler
34
+ }
20
35
  /** @param {import('@wix/origami-types').SiteRouteTable} table */
21
36
  async function prepare(table) {
22
37
  const rules = await access.load(table, root)
23
- const pages = table.pages.map(page => ({ ...page, access: rules.get(page.key) ?? page.access }))
24
- for (const page of pages) for (const rule of page.access) {
25
- if (!accessHandlers.some(handler => handler.type === rule.type)) {
26
- throw new SiteTreeError('UNSUPPORTED_ACCESS_RULE', [page.itemPath], `No access handler for rule type "${rule.type}".`)
27
- }
38
+ const withRules = route => ({ ...route, access: rules.get(route.key) ?? route.access })
39
+ const prepared = {
40
+ ...table,
41
+ pages: table.pages.map(withRules),
42
+ routers: table.routers.map(router => ({
43
+ ...router,
44
+ rolePages: Object.fromEntries(Object.entries(router.rolePages).map(([role, page]) => [role, withRules(page)])),
45
+ })),
28
46
  }
29
- return { ...table, pages }
47
+ for (const route of [...prepared.pages, ...rolePagesOf(prepared)]) for (const rule of route.access) handlerFor(route, rule)
48
+ return prepared
30
49
  }
31
50
  const tree = mode === 'dev' ? createSiteTree(root, prepare) : null
32
51
  const productionTable = tree ? null : await prepare(options.table ?? await readSiteTree(root))
@@ -43,7 +62,59 @@ export async function createSite(options) {
43
62
  if (path === undefined) return { kind: 'notFound' }
44
63
  if (path === '/_origami' || path.startsWith('/_origami/')) return { kind: 'reserved', path }
45
64
  const route = current.pages.find(page => page.url === path)
46
- return route ? { kind: 'page', route } : { kind: 'notFound' }
65
+ if (route) return { kind: 'page', route }
66
+ // The bare prefix and every path below it go to the router: /team, /team/john, /team/a/b/c.
67
+ const [, prefix, ...rest] = path.split('/')
68
+ const router = current.routers.find(candidate => candidate.prefix === prefix)
69
+ return router ? { kind: 'router', route: router, suffix: `/${rest.join('/')}` } : { kind: 'notFound' }
70
+ }
71
+ /** @param {{ itemPath: string, access: import('@wix/origami-types').AccessRule[] }} route @param {Request} request @param {import('../index.js').RequestContext} context */
72
+ async function checkAccess(route, request, context) {
73
+ for (const rule of route.access) {
74
+ const response = await handlerFor(route, rule).check(rule, request, context)
75
+ if (response) return { kind: /** @type {const} */ ('response'), response }
76
+ }
77
+ }
78
+ /** @param {{ key: string, title: string, itemPath?: string }} route @param {200 | 404} status
79
+ * @param {import('../index.js').PageData['route']} routeData @param {Record<string, unknown>} services @param {HeadersInit} [headers] */
80
+ function pageResolution(route, status, routeData, services = {}, headers = {}) {
81
+ const page = { key: route.key, title: route.title, ...(route.itemPath === undefined ? {} : { itemPath: route.itemPath }) }
82
+ return { kind: /** @type {const} */ ('page'), status, page, data: { page: page.key, route: routeData, services }, headers: new Headers(headers) }
83
+ }
84
+ /** @param {Extract<import('../index.js').RouteMatch, { kind: 'router' }>} match @param {URL} url @param {string} path */
85
+ async function resolveRouter(match, url, path, current, request, context) {
86
+ // Answers differ per visitor, so nothing on a router path may be cached, including its 404 and redirects.
87
+ const noStore = { 'cache-control': 'private, no-store' }
88
+ let token
89
+ try { token = await getAccessToken() } catch (error) { return errorPage(error, 503, mode, logger) }
90
+ let answer
91
+ try {
92
+ answer = await callRouter({ router: match.route, suffix: match.suffix, url, token, wixApiBaseUrl, timeoutMs: routerTimeoutMs, fetch })
93
+ } catch (error) {
94
+ if (error instanceof RouterServiceError) return errorPage(error, 503, mode, logger)
95
+ throw error
96
+ }
97
+ if (answer.kind === 'redirect') {
98
+ return { kind: /** @type {const} */ ('response'), response: new Response(null, { status: answer.status, headers: { ...noStore, location: answer.location } }) }
99
+ }
100
+ const rolePage = answer.kind === 'page' ? pickRolePage(match.route, answer.page) : null
101
+ if (answer.kind === 'page' && !rolePage)
102
+ logger.warn(`The router for /${match.route.prefix} answered page "${answer.page}", which no role page in route.json matches.`)
103
+ if (answer.kind !== 'page' || !rolePage) return pageResolution(current.notFound, 404, { kind: 'notFound', url: path }, {}, noStore)
104
+ const denied = await checkAccess(rolePage, request, context)
105
+ if (denied) {
106
+ // A login redirect is per visitor too. Copy into new headers: Response.redirect()'s are immutable.
107
+ const headers = new Headers(denied.response.headers)
108
+ if (!headers.has('cache-control')) headers.set('cache-control', noStore['cache-control'])
109
+ const { status, statusText, body } = denied.response
110
+ return { kind: /** @type {const} */ ('response'), response: new Response(body, { status, statusText, headers }) }
111
+ }
112
+ const externalBaseUrl = url.origin
113
+ return pageResolution(rolePage, 200, { kind: 'router', url: path, role: rolePage.role }, {
114
+ '@wix/viewer-service-dynamic-route': { dynamicRouteData: { pageData: answer.data, publicData: answer.publicData } },
115
+ '@wix/site-service-url': { externalBaseUrl },
116
+ '@wix/viewer-service-url': { externalBaseUrl },
117
+ }, noStore)
47
118
  }
48
119
  return {
49
120
  table,
@@ -70,22 +141,13 @@ export async function createSite(options) {
70
141
  current = await table()
71
142
  match = matchPath(path, current)
72
143
  }
144
+ if (match.kind === 'router') return await resolveRouter(match, url, /** @type {string} */ (path), current, request, context)
73
145
  if (match.kind === 'page') {
74
- for (const rule of match.route.access) {
75
- const handler = accessHandlers.find(candidate => candidate.type === rule.type)
76
- if (!handler) throw new SiteTreeError('UNSUPPORTED_ACCESS_RULE', [match.route.itemPath], `No access handler for rule type "${rule.type}".`)
77
- const response = await handler.check(rule, request, context)
78
- if (response) return { kind: 'response', response }
79
- }
80
- }
81
- const found = match.kind === 'page'
82
- const route = match.kind === 'page' ? match.route : current.notFound
83
- const page = { key: route.key, title: route.title, ...(route.itemPath === undefined ? {} : { itemPath: route.itemPath }) }
84
- return {
85
- kind: 'page', status: found ? 200 : 404, page,
86
- data: { page: page.key, route: { kind: found ? 'page' : 'notFound', url: path ?? url.pathname }, services: {} },
87
- headers: new Headers(),
146
+ const denied = await checkAccess(match.route, request, context)
147
+ if (denied) return denied
148
+ return pageResolution(match.route, 200, { kind: 'page', url: /** @type {string} */ (path) })
88
149
  }
150
+ return pageResolution(current.notFound, 404, { kind: 'notFound', url: path ?? url.pathname })
89
151
  })
90
152
  } catch (error) { return errorPage(error, 500, mode, logger) }
91
153
  },
@@ -1,11 +1,11 @@
1
1
  export class SiteTreeError extends Error {
2
2
  code:
3
- | 'NOT_SUPPORTED_YET' // router pages are not supported in stage 1
4
3
  | 'DUPLICATE_URL' // two folders give the same URL
5
4
  | 'ROUTE_JSON_INVALID' // not JSON, or a required field missing or of the wrong type
6
5
  | 'ROUTE_JSON_MISSING_SERVICE_URL' // no routerServiceUrl: origami refuses to start
7
6
  | 'ROUTE_JSON_SIBLINGS_DIFFER' // [a]/route.json and [b]/route.json differ
8
7
  | 'ROLE_FOLDER_MISSING' // pages.member = 'team-member' but no [team-member]/ folder
8
+ | 'ROLE_PAGE_HAS_NO_FILES' // [x]/ has a route.json but no page files
9
9
  | 'ROLE_FOLDER_NOT_IN_PAGES' // a [x]/ folder that no role in pages names
10
10
  | 'BRACKET_OUTSIDE_PREFIX' // [x]/ not directly under a prefix folder
11
11
  | 'PREFIX_HAS_FILES' // team/ itself holds page files
@@ -19,6 +19,7 @@ export class SiteTreeError extends Error {
19
19
  }
20
20
 
21
21
  export class RouterServiceError extends Error {
22
+ constructor(code: RouterServiceError['code'], routerPrefix: string, message: string, details?: { status?: number; cause?: unknown })
22
23
  code: 'TIMEOUT' | 'NETWORK' | 'HTTP_STATUS' | 'BAD_RESPONSE'
23
24
  routerPrefix: string
24
25
  status?: number
@@ -11,3 +11,19 @@ export class SiteTreeError extends Error {
11
11
  this.paths = [...paths]
12
12
  }
13
13
  }
14
+
15
+ export class RouterServiceError extends Error {
16
+ /**
17
+ * @param {import('./errors.js').RouterServiceError['code']} code
18
+ * @param {string} routerPrefix
19
+ * @param {string} message
20
+ * @param {{ status?: number, cause?: unknown }} [details]
21
+ */
22
+ constructor(code, routerPrefix, message, { status, cause } = {}) {
23
+ super(`RouterServiceError ${code} for /${routerPrefix}: ${message}`, { cause })
24
+ this.name = 'RouterServiceError'
25
+ this.code = code
26
+ this.routerPrefix = routerPrefix
27
+ if (status !== undefined) this.status = status
28
+ }
29
+ }
@@ -0,0 +1,17 @@
1
+ import { RouterServiceError } from '../requests/errors.js'
2
+ import { readRouterAnswer, routerRequest } from './protocol.js'
3
+
4
+ /** One router call. Never cached and never retried: answers differ per visitor, and a POST isn't safe to repeat.
5
+ * @param {{ router: import('@wix/origami-types').RouterRoute, suffix: string, url: URL, token: string,
6
+ * wixApiBaseUrl: string, timeoutMs: number, fetch: typeof fetch }} options */
7
+ export async function callRouter({ fetch, timeoutMs, ...options }) {
8
+ const { prefix } = options.router
9
+ let response
10
+ try {
11
+ response = await fetch(routerRequest(options), { signal: AbortSignal.timeout(timeoutMs) })
12
+ } catch (cause) {
13
+ const timedOut = cause instanceof DOMException && cause.name === 'TimeoutError'
14
+ throw new RouterServiceError(timedOut ? 'TIMEOUT' : 'NETWORK', prefix, timedOut ? `no answer within ${timeoutMs} ms` : 'the request failed', { cause })
15
+ }
16
+ return readRouterAnswer(response, prefix)
17
+ }
@@ -0,0 +1,6 @@
1
+ /** Today the services answer with a Wix page id (route.json pageIds); later with the role itself. One lookup covers both.
2
+ * @param {import('@wix/origami-types').RouterRoute} router @param {string} resultPage */
3
+ export function pickRolePage(router, resultPage) {
4
+ if (Object.hasOwn(router.rolePages, resultPage)) return router.rolePages[resultPage]
5
+ return Object.values(router.rolePages).find(page => page.pageId === resultPage) ?? null
6
+ }
@@ -0,0 +1,44 @@
1
+ // The Wix router service protocol, in one file: a change the router teams ask for is a change here.
2
+ // Piler parity (wix-next builder-utils routerUtils.ts), except that roleVariations is really sent.
3
+ import { RouterServiceError } from '../requests/errors.js'
4
+
5
+ /** @param {{ router: import('@wix/origami-types').RouterRoute, suffix: string, url: URL, token: string, wixApiBaseUrl: string }} options */
6
+ export function routerRequest({ router, suffix, url, token, wixApiBaseUrl }) {
7
+ const { config } = router
8
+ const pageRoles = Object.fromEntries(Object.values(router.rolePages).map(page =>
9
+ [page.role, { id: page.pageId ?? page.role, title: page.title }]))
10
+ return new Request(new URL(`${config.routerServiceUrl}/pages`, wixApiBaseUrl), {
11
+ method: 'POST',
12
+ // The raw token, no "Bearer", as piler sent it.
13
+ headers: { 'content-type': 'application/json', authorization: token },
14
+ body: JSON.stringify({
15
+ routerPrefix: `/${router.prefix}`,
16
+ fullUrl: `${url.protocol}//${url.host}${url.pathname}`,
17
+ routerSuffix: suffix,
18
+ config: config.config,
19
+ pageRoles,
20
+ roleVariations: config.roleVariations,
21
+ requestInfo: { env: 'browser', formFactor: 'desktop' },
22
+ }),
23
+ })
24
+ }
25
+
26
+ /** @typedef {{ kind: 'page', page: string, data: unknown, publicData: unknown } | { kind: 'notFound' } | { kind: 'redirect', status: 301 | 302, location: string }} RouterAnswer */
27
+
28
+ /** @param {Response} response @param {string} routerPrefix @returns {Promise<RouterAnswer>} */
29
+ export async function readRouterAnswer(response, routerPrefix) {
30
+ if (!response.ok) throw new RouterServiceError('HTTP_STATUS', routerPrefix, `the router answered HTTP ${response.status}`, { status: response.status })
31
+ let body
32
+ try { body = await response.json() } catch (cause) { throw new RouterServiceError('BAD_RESPONSE', routerPrefix, 'the body is not JSON', { cause }) }
33
+ const result = body?.result
34
+ if (typeof result !== 'object' || result === null) throw new RouterServiceError('BAD_RESPONSE', routerPrefix, 'the body has no "result"')
35
+ if (result.status === 200 && typeof result.page === 'string') return { kind: 'page', page: result.page, data: result.data ?? {}, publicData: result.publicData ?? {} }
36
+ if ((result.status === 301 || result.status === 302) && typeof result.redirectUrl === 'string') {
37
+ try { new Headers({ location: result.redirectUrl }) } catch (cause) {
38
+ throw new RouterServiceError('BAD_RESPONSE', routerPrefix, 'redirectUrl is not a valid Location header', { cause })
39
+ }
40
+ return { kind: 'redirect', status: result.status, location: result.redirectUrl }
41
+ }
42
+ if (result.status === 404) return { kind: 'notFound' }
43
+ throw new RouterServiceError('BAD_RESPONSE', routerPrefix, `unexpected result status ${JSON.stringify(result.status)}`)
44
+ }
@@ -1,9 +1,10 @@
1
1
  // Ported from origami-site-runtime/routing/pagesToRoutes.js for the layout 2 tree.
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
3
- import { basename, resolve } from 'node:path'
3
+ import { basename, dirname, resolve } from 'node:path'
4
4
  import { parse } from '@babel/parser'
5
5
  import { findItems, pageUrl } from '@wix/origami-types'
6
6
  import { SiteTreeError } from '../requests/errors.js'
7
+ import { canonicalJson, readRouteJson } from './routeJson.js'
7
8
 
8
9
  /** @type {Map<string, { mtimeMs: number, metadata: { title?: string, pageId?: string } }>} */
9
10
  const metadataCache = new Map()
@@ -60,6 +61,63 @@ export const siteRoot = root => {
60
61
  return resolve(root)
61
62
  }
62
63
 
64
+ const isBracket = name => /^\[[^[\]]+\]$/.test(name)
65
+ const isPageFolder = entry => entry.isDirectory() && !entry.name.startsWith('.') && !['_generated', 'node_modules'].includes(entry.name)
66
+ const hasPageFiles = entries => entries.some(entry => entry.isFile() && !entry.name.startsWith('.'))
67
+
68
+ /** A router prefix: `pages/<prefix>/` with only `[slug]` role page folders, each with an identical route.json.
69
+ * @returns {import('@wix/origami-types').RouterRoute} */
70
+ function readRouter(folder, itemPath, entries) {
71
+ const path = `src/${itemPath}`
72
+ const children = entries.filter(isPageFolder)
73
+ const prefix = basename(folder)
74
+ if (itemPath.split('/').length !== 2 || /^\(.*\)$/.test(prefix))
75
+ throw new SiteTreeError('BRACKET_OUTSIDE_PREFIX', children.filter(child => isBracket(child.name)).map(child => `${path}/${child.name}`),
76
+ 'A [page] folder must sit directly in a router prefix folder at the top of src/pages, e.g. src/pages/team/[team-member].')
77
+ if (entries.some(entry => entry.name === 'route.json'))
78
+ throw new SiteTreeError('ROUTE_JSON_INVALID', [`${path}/route.json`], `route.json belongs in each [page] folder of /${prefix}, not in the prefix folder.`)
79
+ if (hasPageFiles(entries))
80
+ throw new SiteTreeError('PREFIX_HAS_FILES', [path], `The router prefix folder "${prefix}" holds only [page] folders; the Wix router answers /${prefix} itself.`)
81
+ const others = children.filter(child => !isBracket(child.name))
82
+ if (others.length)
83
+ throw new SiteTreeError('PREFIX_COLLIDES_WITH_PAGE', others.map(child => `${path}/${child.name}`), `Every URL under /${prefix} goes to the Wix router, so it can't hold other pages.`)
84
+ /** @type {{ slug: string, itemPath: string, path: string, config: import('@wix/origami-types').RouteConfig, title: string }[]} */
85
+ const roleFolders = []
86
+ for (const child of children) {
87
+ const childPath = `${path}/${child.name}`
88
+ const childFolder = resolve(folder, child.name)
89
+ const childEntries = readdirSync(childFolder, { withFileTypes: true })
90
+ const nested = childEntries.filter(isPageFolder)
91
+ if (nested.length) throw new SiteTreeError('ROLE_PAGE_HAS_CHILDREN', nested.map(entry => `${childPath}/${entry.name}`), 'A [page] folder cannot hold other pages.')
92
+ if (!childEntries.some(entry => entry.name === 'route.json'))
93
+ throw new SiteTreeError('ROUTE_JSON_INVALID', [`${childPath}/route.json`], 'Every [page] folder of a router needs its route.json.')
94
+ // Without them the browser would import a page entry that doesn't exist, once the router picks this page.
95
+ if (!hasPageFiles(childEntries.filter(entry => entry.name !== 'route.json')))
96
+ throw new SiteTreeError('ROLE_PAGE_HAS_NO_FILES', [childPath], 'This [page] folder has a route.json but no page files.')
97
+ roleFolders.push({
98
+ slug: child.name.slice(1, -1), itemPath: `${itemPath}/${child.name}`, path: childPath,
99
+ config: readRouteJson(resolve(childFolder, 'route.json'), `${childPath}/route.json`),
100
+ title: pageMetadata(childFolder).title,
101
+ })
102
+ }
103
+ const [first] = roleFolders
104
+ const differ = roleFolders.find(folder => canonicalJson(folder.config) !== canonicalJson(first.config))
105
+ if (differ) throw new SiteTreeError('ROUTE_JSON_SIBLINGS_DIFFER', [`${first.path}/route.json`, `${differ.path}/route.json`],
106
+ 'Role pages of one router must have identical route.json files.')
107
+ const { config } = first
108
+ const unnamed = roleFolders.filter(folder => !Object.values(config.pages).includes(folder.slug))
109
+ if (unnamed.length) throw new SiteTreeError('ROLE_FOLDER_NOT_IN_PAGES', unnamed.map(folder => folder.path), 'No role in route.json "pages" names this folder.')
110
+ /** @type {Record<string, import('@wix/origami-types').RolePage>} */
111
+ const rolePages = {}
112
+ for (const [role, slug] of Object.entries(config.pages)) {
113
+ const folder = roleFolders.find(candidate => candidate.slug === slug)
114
+ if (!folder) throw new SiteTreeError('ROLE_FOLDER_MISSING', [`${path}/[${slug}]`], `route.json names the "${role}" page "${slug}", but there is no [${slug}] folder.`)
115
+ const pageId = config.pageIds?.[role]
116
+ rolePages[role] = { key: folder.itemPath, role, slug, itemPath: folder.itemPath, title: folder.title, access: [], ...(pageId ? { pageId } : {}) }
117
+ }
118
+ return { key: itemPath, prefix, config, rolePages }
119
+ }
120
+
63
121
  /** @param {string} root @returns {Promise<import('@wix/origami-types').SiteRouteTable>} */
64
122
  export const readSiteTree = async root => {
65
123
  const src = resolve(siteRoot(root), 'src')
@@ -68,11 +126,23 @@ export const readSiteTree = async root => {
68
126
  /** @type {import('@wix/origami-types').SiteNotFoundRoute} */
69
127
  let notFound = { key: '@wix/origami-site/default-error-404', title: 'Page not found' }
70
128
  const pathsByUrl = new Map()
71
- const { items, routers } = existsSync(resolve(src, 'pages'))
129
+ const { items, routers: routerPaths } = existsSync(resolve(src, 'pages'))
72
130
  ? findItems(path => readdirSync(resolve(src, path), { withFileTypes: true }), 'pages')
73
131
  : { items: [], routers: [] }
74
- if (routers.length) throw new SiteTreeError('NOT_SUPPORTED_YET', [`src/${routers[0]}`], 'Router pages are not supported yet.')
132
+ /** @type {import('@wix/origami-types').RouterRoute[]} */
133
+ const routers = []
134
+ const prefixes = new Set(routerPaths.filter(path => isBracket(basename(path))).map(path => dirname(path)))
135
+ for (const prefix of prefixes) {
136
+ if (prefix === 'pages/error404')
137
+ throw new SiteTreeError('NOT_FOUND_HAS_CHILDREN', [`src/${prefix}`], 'The not-found page cannot have children.')
138
+ routers.push(readRouter(resolve(src, prefix), prefix, readdirSync(resolve(src, prefix), { withFileTypes: true })))
139
+ }
140
+ for (const path of routerPaths.filter(path => basename(path) === 'route.json')) {
141
+ if (!routers.some(router => Object.values(router.rolePages).some(page => path === `${page.itemPath}/route.json`)))
142
+ throw new SiteTreeError('ROUTE_JSON_INVALID', [`src/${path}`], 'route.json belongs in a [page] folder of a router prefix.')
143
+ }
75
144
  for (const itemPath of items) {
145
+ if (routers.some(router => Object.values(router.rolePages).some(page => page.itemPath === itemPath))) continue
76
146
  const path = `src/${itemPath}`
77
147
  const metadata = pageMetadata(resolve(src, itemPath))
78
148
  if (itemPath === 'pages/error404') notFound = { key: itemPath, itemPath, title: metadata.title }
@@ -88,5 +158,10 @@ export const readSiteTree = async root => {
88
158
  if (notFoundChildren.length)
89
159
  throw new SiteTreeError('NOT_FOUND_HAS_CHILDREN', ['src/pages/error404', ...notFoundChildren.map(page => `src/${page.itemPath}`)],
90
160
  'The not-found page cannot have children.')
91
- return { version: 2, pages, routers: [], notFound, popups: [] }
161
+ for (const router of routers) {
162
+ const page = pages.find(candidate => candidate.url === `/${router.prefix}` || candidate.url.startsWith(`/${router.prefix}/`))
163
+ if (page) throw new SiteTreeError('PREFIX_COLLIDES_WITH_PAGE', [`src/${router.key}`, `src/${page.itemPath}`],
164
+ `Every URL under /${router.prefix} goes to the Wix router, so "${page.url}" can't be a page.`)
165
+ }
166
+ return { version: 2, pages, routers, notFound, popups: [] }
92
167
  }
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { SiteTreeError } from '../requests/errors.js'
3
+
4
+ const isRecord = value => typeof value === 'object' && value !== null && !Array.isArray(value)
5
+ const isStringRecord = value => isRecord(value) && Object.values(value).every(item => typeof item === 'string' && item !== '')
6
+
7
+ /** Reads and checks one route.json. The core reads only routerServiceUrl, pages and pageIds; the rest passes through.
8
+ * @param {string} file absolute path @param {string} path site-relative path for errors
9
+ * @returns {import('@wix/origami-types').RouteConfig} */
10
+ export function readRouteJson(file, path) {
11
+ let config
12
+ try { config = JSON.parse(readFileSync(file, 'utf8')) } catch (error) {
13
+ throw new SiteTreeError('ROUTE_JSON_INVALID', [path], `Not valid JSON: ${/** @type {Error} */ (error).message}`)
14
+ }
15
+ const invalid = message => new SiteTreeError('ROUTE_JSON_INVALID', [path], message)
16
+ if (!isRecord(config)) throw invalid('route.json must hold a JSON object.')
17
+ if (typeof config.routerServiceUrl !== 'string' || !config.routerServiceUrl)
18
+ throw new SiteTreeError('ROUTE_JSON_MISSING_SERVICE_URL', [path], 'route.json has no routerServiceUrl, so the Wix router cannot be called. Export the site again.')
19
+ if (!isStringRecord(config.pages) || !Object.keys(config.pages).length) throw invalid('"pages" must map each role to its page folder name, e.g. { "member": "team-member" }.')
20
+ if (config.pageIds !== undefined && !isStringRecord(config.pageIds)) throw invalid('"pageIds" must map each role to a Wix page id.')
21
+ if (!('config' in config)) throw invalid('"config" is missing.')
22
+ if (!isRecord(config.roleVariations)) throw invalid('"roleVariations" must be an object.')
23
+ return config
24
+ }
25
+
26
+ /** Key order and whitespace don't count when siblings are compared. */
27
+ export const canonicalJson = value => JSON.stringify(value, (_key, item) =>
28
+ isRecord(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
@@ -0,0 +1,34 @@
1
+ // Cookie names from @wix/builder-auth (constants.ts, tokenStore.ts): scoped by `_${siteId}`, expiry in epoch ms.
2
+ const cookie = (header, name) => {
3
+ for (const part of (header ?? '').split(';')) {
4
+ const index = part.indexOf('=')
5
+ if (index > 0 && part.slice(0, index).trim() === name) {
6
+ try { return decodeURIComponent(part.slice(index + 1).trim()) } catch { return undefined }
7
+ }
8
+ }
9
+ }
10
+
11
+ /** The visitor's own builder-auth token when the cookie is there and fresh; otherwise a one-off anonymous token.
12
+ * Stores nothing and sets no cookie: builder-auth in the browser keeps owning the visitor's tokens.
13
+ * @param {{ clientId: string, siteId: string, wixApiBaseUrl?: string, fetch?: typeof fetch, timeoutMs?: number }} options
14
+ * @returns {import('../index.js').TokenProvider} */
15
+ export function builderAuthTokenProvider({ clientId, siteId, wixApiBaseUrl = 'https://www.wixapis.com', fetch = globalThis.fetch, timeoutMs = 5000 }) {
16
+ return {
17
+ async getAccessToken(request) {
18
+ const header = request.headers.get('cookie')
19
+ const token = cookie(header, `wix_access_token_${siteId}`)
20
+ const expiresAt = Number(cookie(header, `wix_token_expires_at_${siteId}`))
21
+ if (token && expiresAt > Date.now()) return token
22
+ if (!clientId) throw new Error('VITE_WIX_CLIENT_ID is not set, so there is no visitor token for the Wix router.')
23
+ const response = await fetch(new URL('/oauth2/token', wixApiBaseUrl), {
24
+ method: 'POST', headers: { 'content-type': 'application/json' },
25
+ body: JSON.stringify({ client_id: clientId, grant_type: 'anonymous' }),
26
+ signal: AbortSignal.timeout(timeoutMs),
27
+ })
28
+ if (!response.ok) throw new Error(`The anonymous token request answered HTTP ${response.status}.`)
29
+ const { access_token: accessToken } = await response.json()
30
+ if (typeof accessToken !== 'string' || !accessToken) throw new Error('The anonymous token response has no access_token.')
31
+ return accessToken
32
+ },
33
+ }
34
+ }