@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.
- package/README.md +79 -0
- package/package.json +68 -0
- package/src/cssPlugins/customMedia.js +52 -0
- package/src/cssPlugins/index.js +18 -0
- package/src/cssPlugins/layout/README.md +77 -0
- package/src/cssPlugins/layout/compiler.js +116 -0
- package/src/cssPlugins/layout/contract.js +35 -0
- package/src/cssPlugins/layout/index.js +12 -0
- package/src/cssPlugins/layout/parser.js +81 -0
- package/src/cssPlugins/mixins.js +23 -0
- package/src/cssPlugins/spx/README.md +73 -0
- package/src/cssPlugins/spx/calls.js +12 -0
- package/src/cssPlugins/spx/cascade.js +41 -0
- package/src/cssPlugins/spx/index.js +13 -0
- package/src/default-error-404.jsx +9 -0
- package/src/index.js +4 -0
- package/src/jsxPlugins/index.js +8 -0
- package/src/jsxPlugins/origamiCompatibility/ast.js +30 -0
- package/src/jsxPlugins/origamiCompatibility/componentBindings.js +43 -0
- package/src/jsxPlugins/origamiCompatibility/index.js +48 -0
- package/src/jsxPlugins/origamiCompatibility/rules/containerProps.js +15 -0
- package/src/jsxPlugins/origamiCompatibility/rules/richTextHtml.js +22 -0
- package/src/routing/documentHtml.js +19 -0
- package/src/routing/pagesToRoutes.js +110 -0
- package/src/routing/siteLayout.js +31 -0
- package/src/routing/urlPath.js +62 -0
- package/src/siteViteConfig.js +91 -0
- package/src/transform/context.js +13 -0
- package/src/transform/css/postcssPlugin.js +35 -0
- package/src/transform/css/runPlugins.js +22 -0
- package/src/transform/jsx/babelAdapter.js +37 -0
- package/src/transform/jsx/runPlugins.js +20 -0
- package/src/transform/pipeline.js +55 -0
- package/src/transform/registry.js +40 -0
- package/src/transform/types.js +39 -0
- package/src/vitePlugins/documentMiddleware.js +42 -0
- package/src/vitePlugins/itemFolderImports.js +16 -0
- package/src/vitePlugins/reactBabel.js +6 -0
- 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,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({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })
|
|
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
|
+
}
|