@wix/origami-transforms 0.1.13 → 0.1.15

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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Every registration in `files`, by its `companionApp.<Name>` type.
3
+ * @param {Iterable<readonly [string, string]>} files Each extension file's path and source.
4
+ * @returns {Map<string, CompanionRegistration>}
5
+ */
6
+ export function readCompanionRegistrations(files: Iterable<readonly [string, string]>): Map<string, CompanionRegistration>;
7
+ /**
8
+ * @typedef {object} CompanionModule
9
+ * @property {CompanionRegistration} registration
10
+ * @property {CompanionImport} import How a page imports it: a component as its entry exports it, a context provider
11
+ * as the entry's default export.
12
+ */
13
+ /**
14
+ * Every `@site/site-code/<name>` a page can import, by its `companionApp` name. A provider's hook is imported from
15
+ * the provider's module.
16
+ * @param {ReadonlyMap<string, CompanionRegistration>} registrations
17
+ * @returns {Map<string, CompanionModule>}
18
+ */
19
+ export function companionModules(registrations: ReadonlyMap<string, CompanionRegistration>): Map<string, CompanionModule>;
20
+ export function isExtensionFile(path: string): boolean;
21
+ export function importTarget(from: string, specifier: string): string;
22
+ export function entryTarget(registration: CompanionRegistration, srcDir: string): string;
23
+ export function findFile(target: string, exists: (path: string) => boolean): string | undefined;
24
+ export function companionExports(modules: ReadonlyMap<string, CompanionModule>, entry: (registration: CompanionRegistration) => string): Record<string, string>;
25
+ export function readCompanionRegistrationsIn(srcDir: string): Map<string, CompanionRegistration>;
26
+ export type CompanionModule = {
27
+ registration: CompanionRegistration;
28
+ /**
29
+ * How a page imports it: a component as its entry exports it, a context provider
30
+ * as the entry's default export.
31
+ */
32
+ import: CompanionImport;
33
+ };
34
+ export type CompanionImport = {
35
+ kind: "default";
36
+ } | {
37
+ kind: "named";
38
+ exportName: string;
39
+ };
40
+ export type CompanionComponentRegistration = {
41
+ kind: "component";
42
+ /**
43
+ * The registration's file.
44
+ */
45
+ path: string;
46
+ /**
47
+ * The client entry's import as written (`./component.tsx?url`), relative to `path`.
48
+ */
49
+ source: string;
50
+ /**
51
+ * How the entry's component is exported.
52
+ */
53
+ componentImport: CompanionImport;
54
+ };
55
+ export type CompanionContextRegistration = {
56
+ kind: "contextProvider";
57
+ /**
58
+ * The registration's file.
59
+ */
60
+ path: string;
61
+ /**
62
+ * `resources.client.url`, relative to the companion app's `src`, as the Wix CLI reads it.
63
+ * The module's default export is the provider.
64
+ */
65
+ source: string;
66
+ /**
67
+ * `resources.contextSpecifier.hook`, the module's named export that reads the context.
68
+ */
69
+ hook: string;
70
+ };
71
+ export type CompanionRegistration = CompanionComponentRegistration | CompanionContextRegistration;
@@ -0,0 +1,2 @@
1
+ /** @type {import('../transform/types.js').CssTransformPlugin} */
2
+ export const componentPositionPlugin: import("../transform/types.js").CssTransformPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-transforms",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Babel and PostCSS checks and transforms for Wix origami page files.",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -15,8 +15,10 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@babel/core": "^7.26.0",
18
+ "@babel/parser": "^7.25.0",
19
+ "@babel/types": "^7.25.2",
18
20
  "@wix/origami-fonts": "1.0.1",
19
- "@wix/origami-system-components": "1.8.0",
21
+ "@wix/origami-system-components": "1.9.0",
20
22
  "postcss": "^8.5.3",
21
23
  "postcss-custom-media": "^12.0.1"
22
24
  },
@@ -54,9 +56,13 @@
54
56
  "./wrapper-import": {
55
57
  "types": "./dist/wrapperImport.d.ts",
56
58
  "default": "./src/wrapperImport.js"
59
+ },
60
+ "./companion-registrations": {
61
+ "types": "./dist/companionRegistrations.d.ts",
62
+ "default": "./src/companionRegistrations.js"
57
63
  }
58
64
  },
59
65
  "type": "module",
60
66
  "types": "./dist/index.d.ts",
61
- "falconPackageHash": "97d30a95762f9e320a620f5de79aa530f7b592e354a3d6776219c7fa"
67
+ "falconPackageHash": "08fa24b6d989c88b62d43cea5a959c50ee2a2212bc6cb279ecf53dc1"
62
68
  }
@@ -0,0 +1,200 @@
1
+ // A companion app's `editorReactComponent` and `contextProvider` registrations, read the same way by the export
2
+ // (origami-site-generator, from the exported files) and by the site's Vite plugin (origami-site-runtime, from disk).
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { posix } from 'node:path'
5
+ import { parse } from '@babel/parser'
6
+ import * as t from '@babel/types'
7
+
8
+ /** @typedef {{ kind: 'default' } | { kind: 'named', exportName: string }} CompanionImport */
9
+ /**
10
+ * @typedef {object} CompanionComponentRegistration
11
+ * @property {'component'} kind
12
+ * @property {string} path The registration's file.
13
+ * @property {string} source The client entry's import as written (`./component.tsx?url`), relative to `path`.
14
+ * @property {CompanionImport} componentImport How the entry's component is exported.
15
+ */
16
+ /**
17
+ * @typedef {object} CompanionContextRegistration
18
+ * @property {'contextProvider'} kind
19
+ * @property {string} path The registration's file.
20
+ * @property {string} source `resources.client.url`, relative to the companion app's `src`, as the Wix CLI reads it.
21
+ * The module's default export is the provider.
22
+ * @property {string} hook `resources.contextSpecifier.hook`, the module's named export that reads the context.
23
+ */
24
+ /** @typedef {CompanionComponentRegistration | CompanionContextRegistration} CompanionRegistration */
25
+
26
+ /** @param {string} path */
27
+ export const isExtensionFile = path => /\.extension\.[cm]?[jt]sx?$/.test(path)
28
+
29
+ /**
30
+ * Every registration in `files`, by its `companionApp.<Name>` type.
31
+ * @param {Iterable<readonly [string, string]>} files Each extension file's path and source.
32
+ * @returns {Map<string, CompanionRegistration>}
33
+ */
34
+ export function readCompanionRegistrations(files) {
35
+ /** @type {Map<string, CompanionRegistration>} */
36
+ const registrations = new Map()
37
+ for (const [path, source] of files) {
38
+ const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript'] })
39
+ t.traverseFast(ast, node => {
40
+ if (!t.isCallExpression(node) || !t.isMemberExpression(node.callee)) return
41
+ const isComponent = t.isIdentifier(node.callee.property, { name: 'editorReactComponent' })
42
+ if (!isComponent && !t.isIdentifier(node.callee.property, { name: 'contextProvider' })) return
43
+ const config = node.arguments[0]
44
+ const type = property(config, 'type')
45
+ const resources = property(config, 'resources')
46
+ const registration = isComponent ? component(ast, path, resources) : contextProvider(path, resources)
47
+ if (!t.isStringLiteral(type)) throw new Error(`Unsupported component registration: ${path}`)
48
+ if (!/^companionApp\.[^.]+$/.test(type.value))
49
+ throw new Error(`Unsupported companion component namespace: ${type.value}`)
50
+ if (registrations.has(type.value)) throw new Error(`Duplicate user code component: ${type.value}`)
51
+ registrations.set(type.value, { ...registration, path })
52
+ })
53
+ }
54
+ return registrations
55
+ }
56
+
57
+ /**
58
+ * @param {t.File} ast @param {string} path @param {t.Expression | undefined} resources
59
+ * @returns {Omit<CompanionComponentRegistration, 'path'>}
60
+ */
61
+ const component = (ast, path, resources) => {
62
+ const url = property(property(resources, 'client'), 'componentUrl')
63
+ if (!t.isIdentifier(url)) throw new Error(`Unsupported component registration: ${path}`)
64
+ const declaration = ast.program.body.find(
65
+ statement =>
66
+ t.isImportDeclaration(statement) && statement.specifiers.some(specifier => specifier.local.name === url.name)
67
+ )
68
+ if (!t.isImportDeclaration(declaration)) throw new Error(`Missing component entry: ${path}`)
69
+ const specifier = declaration.specifiers.find(specifier => specifier.local.name === url.name)
70
+ if (!specifier || t.isImportNamespaceSpecifier(specifier))
71
+ throw new Error(`Unsupported component registration: ${path}`)
72
+ return {
73
+ kind: 'component',
74
+ source: declaration.source.value,
75
+ componentImport: t.isImportDefaultSpecifier(specifier)
76
+ ? { kind: 'default' }
77
+ : {
78
+ kind: 'named',
79
+ exportName: t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value,
80
+ },
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {string} path @param {t.Expression | undefined} resources
86
+ * @returns {Omit<CompanionContextRegistration, 'path'>}
87
+ */
88
+ const contextProvider = (path, resources) => {
89
+ const url = property(property(resources, 'client'), 'url')
90
+ const hook = property(property(resources, 'contextSpecifier'), 'hook')
91
+ if (!t.isStringLiteral(url) || !t.isStringLiteral(hook) || !/^[A-Za-z_$][\w$]*$/.test(hook.value))
92
+ throw new Error(`Unsupported context provider registration: ${path}`)
93
+ return { kind: 'contextProvider', source: url.value, hook: hook.value }
94
+ }
95
+
96
+ /**
97
+ * The file a relative import in `from` points at, without its query, before `findFile` fills in its extension.
98
+ * @param {string} from @param {string} specifier
99
+ */
100
+ export const importTarget = (from, specifier) =>
101
+ posix.normalize(posix.join(posix.dirname(from), specifier.split(/[?#]/)[0]))
102
+
103
+ /**
104
+ * Where a registration's client entry points, before `findFile` fills in its extension.
105
+ * @param {CompanionRegistration} registration @param {string} srcDir The companion app's `src`, in the same form as `path`.
106
+ */
107
+ export const entryTarget = (registration, srcDir) =>
108
+ registration.kind === 'component'
109
+ ? importTarget(registration.path, registration.source)
110
+ : importTarget(posix.join(srcDir, 'index'), registration.source)
111
+
112
+ // The files an import can mean, most likely first: as written, a `.js` import of a TypeScript source, then an added
113
+ // extension or index.
114
+ /** @param {string} target */
115
+ const candidates = target => [
116
+ target,
117
+ ...(/\.[cm]?js$/.test(target)
118
+ ? [
119
+ target.replace(/\.js$/, '.ts'),
120
+ target.replace(/\.js$/, '.tsx'),
121
+ target.replace(/\.mjs$/, '.mts'),
122
+ target.replace(/\.cjs$/, '.cts'),
123
+ ]
124
+ : []),
125
+ ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '/index.ts', '/index.tsx', '/index.js'].map(ext => target + ext),
126
+ ]
127
+
128
+ /**
129
+ * The file an import target (from `importTarget` or `entryTarget`) means, or undefined when none exists.
130
+ * @param {string} target @param {(path: string) => boolean} exists
131
+ */
132
+ export const findFile = (target, exists) => candidates(target).find(exists)
133
+
134
+ /**
135
+ * @typedef {object} CompanionModule
136
+ * @property {CompanionRegistration} registration
137
+ * @property {CompanionImport} import How a page imports it: a component as its entry exports it, a context provider
138
+ * as the entry's default export.
139
+ */
140
+
141
+ /**
142
+ * Every `@site/site-code/<name>` a page can import, by its `companionApp` name. A provider's hook is imported from
143
+ * the provider's module.
144
+ * @param {ReadonlyMap<string, CompanionRegistration>} registrations
145
+ * @returns {Map<string, CompanionModule>}
146
+ */
147
+ export function companionModules(registrations) {
148
+ /** @type {Map<string, CompanionModule>} */
149
+ const modules = new Map()
150
+ for (const [type, registration] of registrations) {
151
+ const name = type.slice('companionApp.'.length)
152
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) throw new Error(`Unsupported extension component name: ${name}`)
153
+ modules.set(name, {
154
+ registration,
155
+ import: registration.kind === 'component' ? registration.componentImport : { kind: 'default' },
156
+ })
157
+ }
158
+ return modules
159
+ }
160
+
161
+ /**
162
+ * The site-code `package.json` `exports` that TypeScript resolves `@site/site-code/<name>` through, one per module.
163
+ * @param {ReadonlyMap<string, CompanionModule>} modules
164
+ * @param {(registration: CompanionRegistration) => string} entry The registration's client entry, relative to the
165
+ * companion app.
166
+ * @returns {Record<string, string>}
167
+ */
168
+ export const companionExports = (modules, entry) =>
169
+ Object.fromEntries([...modules].map(([name, { registration }]) => [`./${name}`, `./${entry(registration)}`]))
170
+
171
+ /**
172
+ * Every registration under a companion app's `src` on disk, with absolute paths; none when it has no `src`.
173
+ * @param {string} srcDir
174
+ */
175
+ export const readCompanionRegistrationsIn = srcDir =>
176
+ readCompanionRegistrations(
177
+ extensionFiles(srcDir).map(file => /** @type {const} */ ([file, readFileSync(file, 'utf8')]))
178
+ )
179
+
180
+ /** @param {string} directory @returns {string[]} */
181
+ const extensionFiles = directory =>
182
+ existsSync(directory)
183
+ ? readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
184
+ const path = posix.join(directory, entry.name)
185
+ if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : extensionFiles(path)
186
+ return isExtensionFile(entry.name) ? [path] : []
187
+ })
188
+ : []
189
+
190
+ /** @param {unknown} node @param {string} name @returns {t.Expression | undefined} */
191
+ const property = (node, name) => {
192
+ if (!node || !t.isObjectExpression(/** @type {t.Node} */ (node))) return undefined
193
+ const match = /** @type {t.ObjectExpression} */ (node).properties.find(
194
+ candidate =>
195
+ t.isObjectProperty(candidate) &&
196
+ !candidate.computed &&
197
+ (t.isIdentifier(candidate.key, { name }) || t.isStringLiteral(candidate.key, { value: name }))
198
+ )
199
+ return t.isObjectProperty(match) && t.isExpression(match.value) ? match.value : undefined
200
+ }
@@ -0,0 +1,26 @@
1
+ // Gives every component the `position: relative` Thunderbolt gives it by default, so what a component positions
2
+ // against itself (the Menu's dropdown) is placed and sized by the component. See componentPosition.md.
3
+
4
+ import { isLayoutSource } from './layout/parser.js'
5
+
6
+ // One class or id: DM writes `.compId`, and layout.css still accepts `#compId` (see layout/parser.js).
7
+ const SINGLE_CLASS_OR_ID = /^[.#][\w-]+$/
8
+
9
+ /** @type {import('../transform/types.js').CssTransformPlugin} */
10
+ export const componentPositionPlugin = {
11
+ name: 'component-position',
12
+ order: 14,
13
+ transform(root, context) {
14
+ // A component's base rules, by selector: DM may write one class in several rules.
15
+ /** @type {Map<string, import('postcss').Rule[]>} */
16
+ const baseRules = new Map()
17
+ for (const node of root.nodes) {
18
+ if (node.type !== 'rule' || !SINGLE_CLASS_OR_ID.test(node.selector) || !isLayoutSource(node, context)) continue
19
+ baseRules.set(node.selector, [...(baseRules.get(node.selector) ?? []), node])
20
+ }
21
+ for (const rules of baseRules.values()) {
22
+ if (rules.some(rule => rule.nodes.some(node => node.type === 'decl' && node.prop === 'position'))) continue
23
+ rules[0].prepend({ prop: 'position', value: 'relative' })
24
+ }
25
+ },
26
+ }
@@ -0,0 +1,34 @@
1
+ # Components are `position: relative` by default
2
+
3
+ Thunderbolt writes `position: relative` into each component's layout CSS when the component's layout sets no
4
+ position, for the default breakpoint and variant only
5
+ ([responsiveLayoutCompNode.ts](https://github.com/wix-private/thunderbolt/blob/09356e43df04152b47de507f73ce2d8e2a74a97a/packages/thunderbolt-catharsis-extensions/src/features/responsiveLayout/responsiveLayoutCompNode.ts#L165-L173)).
6
+ A component's CSS relies on it: the Menu's dropdown is `position: absolute` and as wide as the nearest positioned
7
+ ancestor, which is the Menu only because of this default.
8
+
9
+ DM's export writes a `position` only when the layout data has one, and a `position: relative` written into
10
+ `layout.css` is imported back as data. So the default is added at build time, never in the source:
11
+
12
+ ```css
13
+ /* layout.css */
14
+ .comp-1 { width: 100%; }
15
+ .comp-2 { position: sticky; }
16
+ @media (max-width: 750px) { .comp-1 { width: 50%; } }
17
+ ```
18
+
19
+ builds to
20
+
21
+ ```css
22
+ .comp-1 { position: relative; width: 100%; }
23
+ .comp-2 { position: sticky; }
24
+ @media (max-width: 750px) { .comp-1 { width: 50%; } }
25
+ ```
26
+
27
+ ## The rules
28
+
29
+ - Only a component's base rule gets it: a top-level rule in a site-owned `layout.css` whose selector is one class
30
+ or id (`.comp-1`, a page's `.c1dmp`, or `#comp-1`, which layout.css still accepts). That is the default breakpoint and variant; the `@media` rules inherit it.
31
+ - A rule that sets `position` keeps its own (sticky, or the `absolute` and `fixed` DM writes for pinned and fixed
32
+ items).
33
+ - The declaration goes into the authored rule, so it has the rule's specificity and wins over the component's own
34
+ class rules, as Thunderbolt's `#main_MF :where(.comp-1)` does.
@@ -11,10 +11,12 @@
11
11
  // authored one. custom-enums runs after mixins as well, so an enum a mixin body sets is resolved, and before presets
12
12
  // and layout move declarations out of the instance's rule. fonts appends the `@font-face` rules that load the Wix fonts
13
13
  // the stylesheet names from Wix's CDN.
14
+ // component-position runs after mixins, so a position a mixin body sets is kept, and before layout compiles the rules.
14
15
  //
15
16
  // `@layer` needs no plugin: cascade layers have been in every browser since March 2022.
16
17
 
17
18
  import { customEnumsPlugin } from './customEnums.js'
19
+ import { componentPositionPlugin } from './componentPosition.js'
18
20
  import { customMediaPlugin } from './customMedia.js'
19
21
  import { fontsPlugin } from './fonts.js'
20
22
  import { mixinsPlugin } from './mixins.js'
@@ -31,6 +33,7 @@ export const cssPlugins = Object.freeze([
31
33
  customEnumsPlugin,
32
34
  stackChildrenPlugin,
33
35
  presetsPlugin,
36
+ componentPositionPlugin,
34
37
  layoutPlugin,
35
38
  spxPlugin,
36
39
  ])