@wix/origami-site-runtime 1.0.0

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.
Files changed (39) hide show
  1. package/README.md +79 -0
  2. package/package.json +68 -0
  3. package/src/cssPlugins/customMedia.js +52 -0
  4. package/src/cssPlugins/index.js +18 -0
  5. package/src/cssPlugins/layout/README.md +77 -0
  6. package/src/cssPlugins/layout/compiler.js +116 -0
  7. package/src/cssPlugins/layout/contract.js +35 -0
  8. package/src/cssPlugins/layout/index.js +12 -0
  9. package/src/cssPlugins/layout/parser.js +81 -0
  10. package/src/cssPlugins/mixins.js +23 -0
  11. package/src/cssPlugins/spx/README.md +73 -0
  12. package/src/cssPlugins/spx/calls.js +12 -0
  13. package/src/cssPlugins/spx/cascade.js +41 -0
  14. package/src/cssPlugins/spx/index.js +13 -0
  15. package/src/default-error-404.jsx +9 -0
  16. package/src/index.js +4 -0
  17. package/src/jsxPlugins/index.js +8 -0
  18. package/src/jsxPlugins/origamiCompatibility/ast.js +30 -0
  19. package/src/jsxPlugins/origamiCompatibility/componentBindings.js +43 -0
  20. package/src/jsxPlugins/origamiCompatibility/index.js +48 -0
  21. package/src/jsxPlugins/origamiCompatibility/rules/containerProps.js +15 -0
  22. package/src/jsxPlugins/origamiCompatibility/rules/richTextHtml.js +22 -0
  23. package/src/routing/documentHtml.js +19 -0
  24. package/src/routing/pagesToRoutes.js +110 -0
  25. package/src/routing/siteLayout.js +31 -0
  26. package/src/routing/urlPath.js +62 -0
  27. package/src/siteViteConfig.js +91 -0
  28. package/src/transform/context.js +13 -0
  29. package/src/transform/css/postcssPlugin.js +35 -0
  30. package/src/transform/css/runPlugins.js +22 -0
  31. package/src/transform/jsx/babelAdapter.js +37 -0
  32. package/src/transform/jsx/runPlugins.js +20 -0
  33. package/src/transform/pipeline.js +55 -0
  34. package/src/transform/registry.js +40 -0
  35. package/src/transform/types.js +39 -0
  36. package/src/vitePlugins/documentMiddleware.js +42 -0
  37. package/src/vitePlugins/itemFolderImports.js +16 -0
  38. package/src/vitePlugins/reactBabel.js +6 -0
  39. package/src/vitePlugins/siteDocumentsPlugin.js +35 -0
@@ -0,0 +1,41 @@
1
+ import { lowerCalls } from './calls.js'
2
+
3
+ // Keep arithmetic at the call site: an inherited derived custom property would already
4
+ // be computed against its parent's settings. Only the numeric resolver switch inherits.
5
+ const SCALE = 'var(--origami-spx-scale, 1)'
6
+ const UNIT = `(${SCALE} * (var(--scaling-factor, 100vw) - var(--scrollbar-width, 0px)) / var(--spx-ref-width, 1280) + (1 - ${SCALE}) * 1px)`
7
+ const FLOOR = `calc(0.5px * ${SCALE})`
8
+
9
+ /** Compile settings and calls in place, retaining their cascade and source locations.
10
+ * @param {import('postcss').Declaration} declaration
11
+ */
12
+ export const compileCascadeDeclaration = declaration => {
13
+ if (declaration.prop === '--spx-resolver-type') {
14
+ const resolver = declaration.value.trim()
15
+ if (!['scale', 'fixed'].includes(resolver)) throw declaration.error('Unsupported SPX resolver')
16
+ const value = resolver === 'scale' ? '1' : '0'
17
+ const previous = declaration.prev()
18
+ if (
19
+ previous?.type !== 'decl' ||
20
+ previous.prop !== '--origami-spx-scale' ||
21
+ previous.value !== value ||
22
+ previous.important !== declaration.important
23
+ ) {
24
+ declaration.before(declaration.clone({ prop: '--origami-spx-scale', value }))
25
+ }
26
+ }
27
+ if (
28
+ declaration.prop === '--spx-ref-width' &&
29
+ (!Number.isFinite(Number(declaration.value)) || Number(declaration.value) <= 0)
30
+ ) {
31
+ throw declaration.error('SPX reference width must be positive')
32
+ }
33
+ lowerCalls(
34
+ declaration,
35
+ number => {
36
+ if (number === 0) return '0px'
37
+ return number < 0 ? `min(calc(-1 * ${FLOOR}), ${number} * ${UNIT})` : `max(${FLOOR}, ${number} * ${UNIT})`
38
+ },
39
+ () => declaration.error('SPX calls --spx() with a non-literal argument')
40
+ )
41
+ }
@@ -0,0 +1,13 @@
1
+ import { compileCascadeDeclaration } from './cascade.js'
2
+
3
+ /** @type {import('../../transform/types.js').CssTransformPlugin} */
4
+ export const spxPlugin = {
5
+ name: 'spx',
6
+ order: 20,
7
+ transform(root) {
8
+ root.walkAtRules('function', atRule => {
9
+ if (atRule.params.trimStart().startsWith('--spx(')) atRule.remove()
10
+ })
11
+ root.walkDecls(compileCascadeDeclaration)
12
+ },
13
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import { DefaultError404 } from '@wix/builder-error-pages'
4
+ import { PilerPage } from '@wix/builder-piler-components'
5
+ import '@wix/builder-error-pages/style.css'
6
+
7
+ createRoot(document.getElementById('root')).render(
8
+ React.createElement(React.StrictMode, null, React.createElement(PilerPage, { Page: DefaultError404 }))
9
+ )
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createSiteViteConfig } from './siteViteConfig.js'
2
+ export { cssPlugins } from './cssPlugins/index.js'
3
+ export { jsxPlugins } from './jsxPlugins/index.js'
4
+ export { pagesToRoutes } from './routing/pagesToRoutes.js'
@@ -0,0 +1,8 @@
1
+ // The JSX stage's plugins, in no particular order — the pipeline sorts by each descriptor's
2
+ // `order`. A descriptor needs a `name`, an `order`, and `transform(program, context)`, where
3
+ // `program` is the Babel Program path for the module; see `../transform/types.js`.
4
+
5
+ import { origamiCompatibilityPlugin } from './origamiCompatibility/index.js'
6
+
7
+ /** @type {readonly import('../transform/types.js').JsxTransformPlugin[]} */
8
+ export const jsxPlugins = Object.freeze([origamiCompatibilityPlugin])
@@ -0,0 +1,30 @@
1
+ // Shared JSX/object-literal reads used by the compatibility rules.
2
+
3
+ export const findAttribute = (attributes, name) =>
4
+ attributes.find(attribute => attribute.type === 'JSXAttribute' && attribute.name.name === name)
5
+
6
+ const propertyName = property => {
7
+ if (property.computed) return undefined
8
+ if (property.key.type === 'Identifier') return property.key.name
9
+ if (property.key.type === 'StringLiteral') return property.key.value
10
+ return undefined
11
+ }
12
+
13
+ // Later duplicate keys win, matching how the object literal itself would evaluate.
14
+ export const indexProperties = properties => {
15
+ const fields = new Map()
16
+ for (const property of properties) {
17
+ if (property.type !== 'ObjectProperty') continue
18
+ const name = propertyName(property)
19
+ if (name !== undefined) fields.set(name, property)
20
+ }
21
+ return fields
22
+ }
23
+
24
+ /** The object literal a JSX prop was given, or undefined when it is anything else. */
25
+ export const objectPropValue = (openingElement, name) => {
26
+ const attribute = findAttribute(openingElement.attributes, name)
27
+ if (attribute?.value?.type !== 'JSXExpressionContainer') return undefined
28
+ const { expression } = attribute.value
29
+ return expression.type === 'ObjectExpression' ? expression : undefined
30
+ }
@@ -0,0 +1,43 @@
1
+ // Which local names in a module refer to an Origami component the rules care about. Resolved by
2
+ // import identity rather than by JSX tag name, so a local component that happens to share a name
3
+ // with an Origami one is left alone.
4
+
5
+ const ORIGAMI_MODULES = new Set(['document', 'document/components', 'wixEditorElements'])
6
+ const COMPONENTS_DIRECTORY = 'components'
7
+
8
+ const importedName = specifier => {
9
+ if (specifier.type !== 'ImportSpecifier') return undefined
10
+ return specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value
11
+ }
12
+
13
+ // A default import is an Origami component only when it comes from a bare `.../components/<Name>`
14
+ // specifier; relative paths are the site's own global components.
15
+ const defaultComponentName = source => {
16
+ if (source.startsWith('.') || source.startsWith('/')) return undefined
17
+
18
+ const segments = source.replace(/\/+$/u, '').split('/')
19
+ return segments.at(-2) === COMPONENTS_DIRECTORY ? segments.at(-1) : undefined
20
+ }
21
+
22
+ const componentName = (source, specifier) => {
23
+ if (specifier.type === 'ImportDefaultSpecifier') return defaultComponentName(source)
24
+ return ORIGAMI_MODULES.has(source) ? importedName(specifier) : undefined
25
+ }
26
+
27
+ /**
28
+ * @param {object} program
29
+ * @param {Map<string, readonly object[]>} rulesByComponent
30
+ * @returns {Map<string, readonly object[]>} local binding name -> rules to apply to it
31
+ */
32
+ export const collectComponentBindings = (program, rulesByComponent) => {
33
+ const bindings = new Map()
34
+ for (const statement of program.body) {
35
+ if (statement.type !== 'ImportDeclaration') continue
36
+ for (const specifier of statement.specifiers) {
37
+ const name = componentName(statement.source.value, specifier)
38
+ const rules = name === undefined ? undefined : rulesByComponent.get(name)
39
+ if (rules !== undefined) bindings.set(specifier.local.name, rules)
40
+ }
41
+ }
42
+ return bindings
43
+ }
@@ -0,0 +1,48 @@
1
+ // Bridges generated Origami structures to the piler component contract by forcing a few props onto
2
+ // the components that need them.
3
+ //
4
+ // Each forced prop is a rule in `rules/`, declaring the components it applies to. That table is
5
+ // also what defines which imported names count as Origami components, so adding a rule extends
6
+ // import recognition with it — there is no second list to keep in sync.
7
+
8
+ import { basename } from 'node:path'
9
+ import { collectComponentBindings } from './componentBindings.js'
10
+ import { containerPropsRule } from './rules/containerProps.js'
11
+ import { richTextHtmlRule } from './rules/richTextHtml.js'
12
+
13
+ const STRUCTURE_FILE = 'structure.jsx'
14
+
15
+ const RULES = [containerPropsRule, richTextHtmlRule]
16
+
17
+ /** Origami component name -> the rules that force props on it. */
18
+ const RULES_BY_COMPONENT = new Map(
19
+ RULES.flatMap(rule => rule.components).map(component => [
20
+ component,
21
+ RULES.filter(rule => rule.components.includes(component)),
22
+ ])
23
+ )
24
+
25
+ /** @type {import('../../transform/types.js').JsxTransformPlugin} */
26
+ export const origamiCompatibilityPlugin = {
27
+ name: 'wix-import-origami-compatibility',
28
+ order: 0,
29
+ appliesTo: context => basename(context.filePath) === STRUCTURE_FILE,
30
+
31
+ transform: program => {
32
+ // Import identity, not tag name, decides what is an Origami component — so a module that
33
+ // imports none cannot match a rule, and the tree is never walked.
34
+ const rulesByLocalName = collectComponentBindings(program.node, RULES_BY_COMPONENT)
35
+ if (rulesByLocalName.size === 0) return
36
+
37
+ program.traverse({
38
+ JSXOpeningElement(path) {
39
+ if (path.node.name.type !== 'JSXIdentifier') return
40
+
41
+ const rules = rulesByLocalName.get(path.node.name.name)
42
+ if (rules === undefined) return
43
+
44
+ for (const rule of rules) rule.apply(path.node)
45
+ },
46
+ })
47
+ },
48
+ }
@@ -0,0 +1,15 @@
1
+ // Container components need a `containerProps` object to satisfy the piler component contract.
2
+
3
+ import { types as t } from '@babel/core'
4
+ import { findAttribute } from '../ast.js'
5
+
6
+ export const containerPropsRule = {
7
+ components: ['Section', 'RefComponent', 'HeaderSection', 'FooterSection'],
8
+ apply: openingElement => {
9
+ if (findAttribute(openingElement.attributes, 'containerProps')) return
10
+
11
+ openingElement.attributes.push(
12
+ t.jsxAttribute(t.jsxIdentifier('containerProps'), t.jsxExpressionContainer(t.objectExpression([])))
13
+ )
14
+ },
15
+ }
@@ -0,0 +1,22 @@
1
+ // Rich text needs an `html` field, which the export only provides as `text`.
2
+
3
+ import { types as t } from '@babel/core'
4
+ import { indexProperties, objectPropValue } from '../ast.js'
5
+
6
+ export const richTextHtmlRule = {
7
+ components: ['WRichText'],
8
+ apply: openingElement => {
9
+ const richText = objectPropValue(openingElement, 'richText')
10
+ if (!richText) return
11
+
12
+ const fields = indexProperties(richText.properties)
13
+ if (fields.has('html')) return
14
+
15
+ // Only a literal text/linkList pair is safe to derive from statically.
16
+ const text = fields.get('text')
17
+ const linkList = fields.get('linkList')
18
+ if (text?.value.type !== 'StringLiteral' || linkList?.value.type !== 'ArrayExpression') return
19
+
20
+ richText.properties.push(t.objectProperty(t.identifier('html'), t.cloneNode(text.value, true)))
21
+ },
22
+ }
@@ -0,0 +1,19 @@
1
+ // Fill the title and entry placeholders in src/site/document.html.
2
+ const TITLE_PLACEHOLDER = '<!-- WIX_IMPORT_TITLE -->'
3
+ const ENTRY_PLACEHOLDER = '<!-- WIX_IMPORT_ENTRY -->'
4
+ const HTML_ENTITIES = Object.freeze({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })
5
+ const escapeHtml = value => String(value).replace(/[&<>"']/g, character => HTML_ENTITIES[character])
6
+
7
+ // Function replacements keep dollar sequences in data literal.
8
+ const fillPlaceholder = (template, placeholder, value) => template.replace(placeholder, () => value)
9
+ const renderEntryScript = entry =>
10
+ entry.startsWith('/')
11
+ ? `<script type="module" src="${escapeHtml(entry)}"></script>`
12
+ : `<script type="module">import ${JSON.stringify(entry).replace(/</g, '\\u003c')}</script>`
13
+
14
+ export const renderPageDocument = ({ template, route }) =>
15
+ fillPlaceholder(
16
+ fillPlaceholder(template, TITLE_PLACEHOLDER, escapeHtml(route.title)),
17
+ ENTRY_PLACEHOLDER,
18
+ renderEntryScript(route.entry)
19
+ )
@@ -0,0 +1,110 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { basename, resolve } from 'node:path'
3
+ import { parse } from '@babel/parser'
4
+ import * as t from '@babel/types'
5
+ import { runtimeDefaultError404Module, siteLayout } from './siteLayout.js'
6
+
7
+ // Dev re-walks the pages on every document request, so a title is parsed again only when its
8
+ // structure.jsx changed.
9
+ /** @type {Map<string, { mtimeMs: number, title: string | undefined }>} */
10
+ const titleCache = new Map()
11
+
12
+ const pageTitle = (folder, fallback) => {
13
+ const structure = resolve(folder, 'structure.jsx')
14
+ if (!existsSync(structure)) return fallback
15
+ const { mtimeMs } = statSync(structure)
16
+ let cached = titleCache.get(structure)
17
+ if (cached?.mtimeMs !== mtimeMs) {
18
+ cached = { mtimeMs, title: readTitle(structure) }
19
+ titleCache.set(structure, cached)
20
+ }
21
+ return cached.title ?? fallback
22
+ }
23
+
24
+ const readTitle = structure => {
25
+ const ast = parse(readFileSync(structure, 'utf8'), { sourceType: 'module', plugins: ['jsx'] })
26
+ let title
27
+ t.traverseFast(ast, node => {
28
+ if (title !== undefined || !t.isJSXOpeningElement(node) || !t.isJSXIdentifier(node.name, { name: 'Page' }))
29
+ return
30
+ const data = node.attributes.find(
31
+ attribute => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'data' })
32
+ )
33
+ if (
34
+ !t.isJSXAttribute(data) ||
35
+ !t.isJSXExpressionContainer(data.value) ||
36
+ !t.isObjectExpression(data.value.expression)
37
+ )
38
+ return
39
+ const property = data.value.expression.properties.find(
40
+ candidate =>
41
+ t.isObjectProperty(candidate) &&
42
+ !candidate.computed &&
43
+ (t.isIdentifier(candidate.key, { name: 'title' }) ||
44
+ t.isStringLiteral(candidate.key, { value: 'title' }))
45
+ )
46
+ if (t.isObjectProperty(property) && t.isStringLiteral(property.value)) title = property.value.value
47
+ })
48
+ return title
49
+ }
50
+
51
+ const findPageItems = layout => {
52
+ const items = []
53
+ const walk = itemPath => {
54
+ const folder = resolve(layout.root, 'src', itemPath)
55
+ const entries = readdirSync(folder, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
56
+ if (/^\[.*\]$/.test(basename(folder)) || entries.some(entry => entry.name === 'route.json'))
57
+ throw new Error(`Router pages are not supported yet: src/${itemPath}`)
58
+ if (entries.some(entry => entry.isFile() && !entry.name.startsWith('.'))) {
59
+ items.push({
60
+ itemPath,
61
+ title: pageTitle(folder, basename(folder).replace(/^\(([^()]+)\)$/, '$1')),
62
+ entry: `/src/${itemPath}/_generated/index.jsx`,
63
+ })
64
+ }
65
+ for (const entry of entries) {
66
+ if (
67
+ entry.isDirectory() &&
68
+ !entry.name.startsWith('.') &&
69
+ !['_generated', 'node_modules'].includes(entry.name)
70
+ )
71
+ walk(`${itemPath}/${entry.name}`)
72
+ }
73
+ }
74
+ if (existsSync(layout.pagesDir)) walk('pages')
75
+ return items
76
+ }
77
+
78
+ /** @returns {import('@wix/origami-types').RouteTable} */
79
+ const buildRouteTable = items => {
80
+ const notFound = items.find(item => item.itemPath === 'pages/error404') ?? {
81
+ title: 'Page not found',
82
+ entry: runtimeDefaultError404Module,
83
+ }
84
+ const routes = items
85
+ .filter(item => item.itemPath !== 'pages/error404')
86
+ .map(item => ({
87
+ type: /** @type {const} */ ('static'),
88
+ url:
89
+ '/' +
90
+ item.itemPath
91
+ .slice('pages/'.length)
92
+ .split('/')
93
+ .filter(segment => !/^\([^()]+\)$/.test(segment))
94
+ .join('/'),
95
+ ...item,
96
+ }))
97
+ const pathsByUrl = new Map()
98
+ for (const route of routes) {
99
+ const previous = pathsByUrl.get(route.url)
100
+ if (previous) throw new Error(`Pages "${previous}" and "${route.itemPath}" both serve "${route.url}".`)
101
+ pathsByUrl.set(route.url, route.itemPath)
102
+ }
103
+ return { routes, notFound, popups: [] }
104
+ }
105
+
106
+ /** @returns {import('@wix/origami-types').RouteTable} */
107
+ export const pagesToRoutes = root => {
108
+ const layout = siteLayout(root)
109
+ return buildRouteTable(findPageItems(layout))
110
+ }
@@ -0,0 +1,31 @@
1
+ // Single source of truth for where things live: the on-disk shape of a generated site, and the
2
+ // module id this package exposes to a generated site's browser bundle.
3
+
4
+ import { createRequire } from 'node:module'
5
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
6
+
7
+ export const runtimeDefaultError404Module = '@wix/origami-site-runtime/default-error-404'
8
+
9
+ export const runtimeDefaultError404Path = createRequire(import.meta.url).resolve(runtimeDefaultError404Module)
10
+
11
+ export const siteLayout = root => {
12
+ if (typeof root !== 'string' || !root) throw new TypeError('root must be a non-empty string.')
13
+ const rootPath = resolve(root)
14
+
15
+ return Object.freeze({
16
+ root: rootPath,
17
+ pagesDir: resolve(rootPath, 'src/pages'),
18
+ template: resolve(rootPath, 'src/site/document.html'),
19
+ outDir: 'dist',
20
+ documentId: url =>
21
+ url === undefined ? resolve(rootPath, '404.html') : resolve(rootPath, `.${url}`, 'index.html'),
22
+ })
23
+ }
24
+
25
+ // Path of `id` relative to the site root in posix form, or undefined when `id` escapes the root.
26
+ export const relativeToSite = (root, id) => {
27
+ const filePath = id.split('?', 1)[0]
28
+ const fromRoot = relative(resolve(root), filePath)
29
+ if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) return undefined
30
+ return fromRoot.split(sep).join('/')
31
+ }
@@ -0,0 +1,62 @@
1
+ // URL path normalization shared by route declarations and incoming requests. A normalized path is
2
+ // absolute, percent-decoded, and free of anything that could escape the route table: traversal
3
+ // segments, embedded separators, control characters.
4
+
5
+ const RESERVED_IN_RAW_PATH = /[\\?#]/
6
+ const SEGMENT_SEPARATORS = '\\/?#'
7
+ const HIGHEST_CONTROL_CODE = 0x1f
8
+ const DELETE_CODE = 0x7f
9
+
10
+ export class UrlPathError extends Error {
11
+ constructor(reason) {
12
+ super(reason)
13
+ this.name = 'UrlPathError'
14
+ this.reason = reason
15
+ }
16
+ }
17
+
18
+ const hasUnsafeCharacter = value => {
19
+ for (let index = 0; index < value.length; index += 1) {
20
+ const code = value.charCodeAt(index)
21
+ if (code <= HIGHEST_CONTROL_CODE || code === DELETE_CODE) return true
22
+ if (SEGMENT_SEPARATORS.includes(value[index])) return true
23
+ }
24
+ return false
25
+ }
26
+
27
+ const decodeSegment = segment => {
28
+ let decoded
29
+ try {
30
+ decoded = decodeURIComponent(segment)
31
+ } catch {
32
+ throw new UrlPathError('unsafe-segment')
33
+ }
34
+ if (decoded === '.' || decoded === '..' || hasUnsafeCharacter(decoded)) throw new UrlPathError('unsafe-segment')
35
+ return decoded
36
+ }
37
+
38
+ export const normalizeUrlPath = value => {
39
+ if (
40
+ typeof value !== 'string' ||
41
+ !value.startsWith('/') ||
42
+ RESERVED_IN_RAW_PATH.test(value) ||
43
+ value.includes('//')
44
+ ) {
45
+ throw new UrlPathError('not-absolute')
46
+ }
47
+
48
+ const trimmed = value === '/' ? value : value.replace(/\/+$/, '')
49
+ if (trimmed === '/') return trimmed
50
+
51
+ return `/${trimmed.slice(1).split('/').map(decodeSegment).join('/')}`
52
+ }
53
+
54
+ // Requests are attacker-controlled, so an unusable path is a routing outcome (404), not an error.
55
+ export const normalizeRequestPath = requestUrl => {
56
+ if (typeof requestUrl !== 'string' || !requestUrl.startsWith('/')) return undefined
57
+ try {
58
+ return normalizeUrlPath(new URL(requestUrl, 'http://site.local').pathname)
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
@@ -0,0 +1,91 @@
1
+ // Vite configuration for a generated wix-import site: a multi-page app whose HTML entries are
2
+ // derived from item folders.
3
+
4
+ import { dirname, relative, sep } from 'node:path'
5
+ import { defineConfig, searchForWorkspaceRoot } from 'vite'
6
+ import { pagesToRoutes } from './routing/pagesToRoutes.js'
7
+ import { itemFolderImports } from './vitePlugins/itemFolderImports.js'
8
+ import { runtimeDefaultError404Path, siteLayout } from './routing/siteLayout.js'
9
+ import { createTransformPipeline } from './transform/pipeline.js'
10
+ import { reactBabelPlugin } from './vitePlugins/reactBabel.js'
11
+ import { siteDocumentsPlugin } from './vitePlugins/siteDocumentsPlugin.js'
12
+
13
+ const DEFAULT_DEVELOPMENT_PORT = 3000
14
+
15
+ // One virtual HTML document per render target. The same table drives Rollup's inputs and the
16
+ // plugin's resolveId/load pair, so build entries and served documents cannot drift apart.
17
+ const collectPageDocuments = layout => {
18
+ const table = pagesToRoutes(layout.root)
19
+ const documents = new Map()
20
+ for (const route of table.routes) documents.set(layout.documentId(route.url), route)
21
+ documents.set(layout.documentId(), table.notFound)
22
+ return documents
23
+ }
24
+
25
+ const isStylesheet = asset => (asset.names?.[0] ?? asset.originalFileNames?.[0] ?? '').endsWith('.css')
26
+
27
+ /**
28
+ * @param {{
29
+ * root?: string,
30
+ * jsxPlugins?: readonly import('./transform/types.js').JsxTransformPlugin[],
31
+ * cssPlugins?: readonly import('./transform/types.js').CssTransformPlugin[],
32
+ * }} [options]
33
+ */
34
+ export const createSiteViteConfig = ({ root, jsxPlugins, cssPlugins } = {}) => {
35
+ const layout = siteLayout(root)
36
+ const documents = collectPageDocuments(layout)
37
+ const pipeline = createTransformPipeline({
38
+ layout,
39
+ ...(jsxPlugins ? { jsxPlugins } : {}),
40
+ ...(cssPlugins ? { cssPlugins } : {}),
41
+ })
42
+
43
+ return defineConfig({
44
+ root: layout.root,
45
+ appType: 'custom',
46
+ // The host and user code must share service contexts and signal tracking scopes.
47
+ resolve: {
48
+ dedupe: [
49
+ 'react',
50
+ 'react-dom',
51
+ '@wix/services-manager-react',
52
+ '@wix/sdk-react-context',
53
+ '@preact/signals-react',
54
+ '@preact/signals-core',
55
+ ],
56
+ },
57
+ plugins: [
58
+ itemFolderImports(layout.root),
59
+ siteDocumentsPlugin({ layout, documents, pipeline }),
60
+ reactBabelPlugin({ pipeline }),
61
+ ],
62
+ // Stating the PostCSS plugins here also opts out of Vite's search for a project postcss
63
+ // config, which a generated site does not have. The CSS stage has to run as a PostCSS
64
+ // plugin rather than a Vite one — see transform/css/postcssPlugin.js.
65
+ css: { postcss: { plugins: [pipeline.postcssPlugin] } },
66
+ server: {
67
+ port: DEFAULT_DEVELOPMENT_PORT,
68
+ fs: { allow: [searchForWorkspaceRoot(layout.root), dirname(runtimeDefaultError404Path)] },
69
+ },
70
+ build: {
71
+ outDir: layout.outDir,
72
+ rollupOptions: {
73
+ input: Object.fromEntries(
74
+ [...documents.keys()].map(id => [
75
+ relative(layout.root, id)
76
+ .split(sep)
77
+ .join('/')
78
+ .replace(/\.html$/, ''),
79
+ id,
80
+ ])
81
+ ),
82
+ output: {
83
+ entryFileNames: 'js/[name]-[hash].js',
84
+ chunkFileNames: 'js/[name]-[hash].js',
85
+ assetFileNames: asset =>
86
+ isStylesheet(asset) ? 'css/[name]-[hash][extname]' : 'assets/[name]-[hash][extname]',
87
+ },
88
+ },
89
+ },
90
+ })
91
+ }
@@ -0,0 +1,13 @@
1
+ import { relativeToSite } from '../routing/siteLayout.js'
2
+
3
+ export const createContextFactory =
4
+ ({ layout, command }) =>
5
+ (id, { ssr = false } = {}) =>
6
+ Object.freeze({
7
+ id,
8
+ filePath: id.split('?', 1)[0],
9
+ relativePath: relativeToSite(layout.root, id),
10
+ layout,
11
+ command,
12
+ ssr,
13
+ })
@@ -0,0 +1,35 @@
1
+ // The CSS stage, exposed as a PostCSS plugin rather than a Vite `transform` hook.
2
+ //
3
+ // That placement is load-bearing. A generated page imports one stylesheet that `@import`s the
4
+ // Origami carriers (layout.css, style.css); Vite inlines those with its own postcss-import, which
5
+ // reads them straight from disk and never runs plugin transform hooks over them. A Vite hook at
6
+ // `enforce: 'pre'` therefore sees the page's `@import` statements and nothing else. Vite installs
7
+ // postcss-import ahead of the configured PostCSS plugins, so by the time this one runs the
8
+ // stylesheet the plugins see is the whole page: a rule declared in one carrier and a rule that
9
+ // depends on it in another arrive in the same document.
10
+
11
+ import { runCssPlugins } from './runPlugins.js'
12
+
13
+ const DEPENDENCY_DIRECTORY = 'node_modules/'
14
+
15
+ // The site's own files, and only those. A stylesheet outside the root belongs to a dependency; so
16
+ // does one reached through the site's own node_modules, which merely happens to live inside it.
17
+ const isSiteOwned = relativePath =>
18
+ relativePath !== undefined &&
19
+ !relativePath.startsWith(DEPENDENCY_DIRECTORY) &&
20
+ !relativePath.includes(`/${DEPENDENCY_DIRECTORY}`)
21
+
22
+ export const createCssPostcssPlugin = ({ selectPlugins, contextFor }) => ({
23
+ postcssPlugin: 'wix-import-css-transform',
24
+
25
+ Once(root, { result }) {
26
+ const from = result.opts.from
27
+ if (from === undefined) return
28
+
29
+ const context = contextFor(from)
30
+ if (!isSiteOwned(context.relativePath)) return
31
+
32
+ const plugins = selectPlugins(context)
33
+ if (plugins.length) runCssPlugins({ plugins, root, context })
34
+ },
35
+ })
@@ -0,0 +1,22 @@
1
+ // Runs the selected CSS plugins over one shared Root, in order. Parsing, stringifying and source
2
+ // maps belong to PostCSS, which drives this stage — the only thing added here is attributing a
3
+ // failure to the plugin and the file that caused it.
4
+
5
+ const reason = cause => (cause instanceof Error ? cause.message : String(cause))
6
+
7
+ export const runCssPlugins = ({ plugins, root, context }) => {
8
+ for (const phase of ['validate', 'transform']) {
9
+ for (const plugin of plugins) {
10
+ try {
11
+ plugin[phase]?.(root, context)
12
+ } catch (cause) {
13
+ // Tooling such as Vite's error overlay prints only the message, so the reason goes in it as well. A
14
+ // plugin may throw any value, so it is read without assuming an Error.
15
+ throw new Error(
16
+ `CSS transform "${plugin.name}" failed for ${context.relativePath ?? context.filePath}: ${reason(cause)}`,
17
+ { cause }
18
+ )
19
+ }
20
+ }
21
+ }
22
+ }