@wix/origami-transforms 0.1.27 → 0.1.29

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 (58) hide show
  1. package/dist/cssPlugins/componentPosition.d.ts +6 -2
  2. package/dist/cssPlugins/customEnums.d.ts +5 -2
  3. package/dist/cssPlugins/customMedia.d.ts +7 -2
  4. package/dist/cssPlugins/fonts.d.ts +6 -2
  5. package/dist/cssPlugins/index.d.ts +2 -2
  6. package/dist/cssPlugins/layout/index.d.ts +7 -2
  7. package/dist/cssPlugins/mixins.d.ts +7 -2
  8. package/dist/cssPlugins/presets.d.ts +6 -2
  9. package/dist/cssPlugins/shapeDividers.d.ts +6 -2
  10. package/dist/cssPlugins/spx/index.d.ts +7 -2
  11. package/dist/cssPlugins/stackChildren.d.ts +5 -2
  12. package/dist/cssPlugins/svg.d.ts +4 -1
  13. package/dist/cssPlugins/wixVectorUrls.d.ts +6 -2
  14. package/dist/jsxPlugins/index.d.ts +2 -2
  15. package/dist/jsxPlugins/mediaValidation/index.d.ts +5 -2
  16. package/dist/jsxPlugins/origamiCompatibility/index.d.ts +6 -2
  17. package/dist/jsxPlugins/pageSlots.d.ts +7 -6
  18. package/dist/jsxPlugins/validation/checks.d.ts +7 -7
  19. package/dist/jsxPlugins/validation/index.d.ts +5 -2
  20. package/dist/jsxPlugins/validation/rules.d.ts +25 -0
  21. package/dist/transform/bi.d.ts +13 -0
  22. package/dist/transform/context.d.ts +1 -0
  23. package/dist/transform/css/runPlugins.d.ts +4 -1
  24. package/dist/transform/layoutError.d.ts +8 -4
  25. package/dist/transform/pipeline.d.ts +7 -9
  26. package/dist/transform/types.d.ts +16 -13
  27. package/package.json +6 -4
  28. package/src/cssPlugins/componentPosition.js +5 -1
  29. package/src/cssPlugins/customEnums.js +19 -12
  30. package/src/cssPlugins/customMedia.js +10 -3
  31. package/src/cssPlugins/fonts.js +5 -1
  32. package/src/cssPlugins/index.js +1 -1
  33. package/src/cssPlugins/layout/index.js +8 -1
  34. package/src/cssPlugins/layout/parser.js +15 -14
  35. package/src/cssPlugins/mixins.js +13 -6
  36. package/src/cssPlugins/presets.js +7 -2
  37. package/src/cssPlugins/shapeDividers.js +6 -2
  38. package/src/cssPlugins/spx/calls.js +2 -1
  39. package/src/cssPlugins/spx/cascade.js +3 -2
  40. package/src/cssPlugins/spx/index.js +8 -1
  41. package/src/cssPlugins/stackChildren.js +6 -2
  42. package/src/cssPlugins/svg.js +5 -2
  43. package/src/cssPlugins/wixVectorUrls.js +6 -2
  44. package/src/jsxPlugins/index.js +1 -1
  45. package/src/jsxPlugins/mediaValidation/index.js +14 -5
  46. package/src/jsxPlugins/origamiCompatibility/index.js +5 -1
  47. package/src/jsxPlugins/pageSlots.js +6 -1
  48. package/src/jsxPlugins/validation/checks.js +15 -8
  49. package/src/jsxPlugins/validation/index.js +9 -3
  50. package/src/jsxPlugins/validation/rules.js +14 -0
  51. package/src/transform/bi.js +64 -0
  52. package/src/transform/context.js +39 -0
  53. package/src/transform/css/postcssPlugin.js +18 -3
  54. package/src/transform/css/runPlugins.js +23 -3
  55. package/src/transform/jsx/runPlugins.js +17 -2
  56. package/src/transform/layoutError.js +8 -3
  57. package/src/transform/pipeline.js +25 -5
  58. package/src/transform/types.js +30 -8
@@ -1,3 +1,4 @@
1
+ import { fail } from './index.js'
1
2
  import { basename } from 'node:path'
2
3
  import { isValidContainerId } from '@wix/origami-system-components/layout-contract'
3
4
  import { relativeToSite } from '../../transform/relativeToSite.js'
@@ -20,7 +21,7 @@ export const parseTargets = (root, context) => {
20
21
  const owners = new Map()
21
22
  root.walkAtRules('origami-target', target => {
22
23
  const name = target.params.trim()
23
- if (!Object.hasOwn(targets, name)) throw target.error(`Unknown @origami-target ${name}`)
24
+ if (!Object.hasOwn(targets, name)) throw fail('unknown_target', target.error(`Unknown @origami-target ${name}`))
24
25
  const owner = target.parent
25
26
  // DM writes component rules as .compId; #compId is still accepted.
26
27
  if (
@@ -28,34 +29,34 @@ export const parseTargets = (root, context) => {
28
29
  !['.', '#'].includes(owner.selector[0]) ||
29
30
  !isValidContainerId(owner.selector.slice(1))
30
31
  ) {
31
- throw target.error('@origami-target requires a single component ID rule')
32
+ throw fail('target_requires_component_id', target.error('@origami-target requires a single component ID rule'))
32
33
  }
33
34
  for (let ancestor = owner.parent; ancestor && ancestor.type !== 'root'; ancestor = ancestor.parent) {
34
35
  if (
35
36
  ancestor.type !== 'atrule' ||
36
37
  !['scope', 'media', 'layer', 'supports', 'container'].includes(ancestor.name)
37
38
  ) {
38
- throw target.error('@origami-target is only allowed inside layout rule envelopes')
39
+ throw fail('invalid_target_envelope', target.error('@origami-target is only allowed inside layout rule envelopes'))
39
40
  }
40
41
  }
41
42
  if (!isLayoutSource(target, context))
42
- throw target.error('@origami-target must originate in site-owned layout.css')
43
+ throw fail('target_not_site_layout', target.error('@origami-target must originate in site-owned layout.css'))
43
44
  const siblings = owners.get(owner) ?? []
44
- if (siblings.some(sibling => sibling.params.trim() === name)) throw target.error(`Duplicate target ${name}`)
45
+ if (siblings.some(sibling => sibling.params.trim() === name)) throw fail('duplicate_target', target.error(`Duplicate target ${name}`))
45
46
  const seen = new Set()
46
47
  for (const node of target.nodes ?? []) {
47
48
  if (node.type === 'comment') continue
48
- if (node.type !== 'decl') throw node.error('Target bodies must contain declarations only')
49
- if (seen.has(node.prop)) throw node.error(`Duplicate target property ${node.prop}`)
49
+ if (node.type !== 'decl') throw fail('target_requires_declarations', node.error('Target bodies must contain declarations only'))
50
+ if (seen.has(node.prop)) throw fail('duplicate_target_property', node.error(`Duplicate target property ${node.prop}`))
50
51
  seen.add(node.prop)
51
- if (!targets[name].has(node.prop)) throw node.error(`${node.prop} does not belong to ${name}`)
52
+ if (!targets[name].has(node.prop)) throw fail('property_wrong_target', node.error(`${node.prop} does not belong to ${name}`))
52
53
  if (node.prop === 'position' && node.value !== 'relative')
53
- throw node.error('Content position must be relative')
54
+ throw fail('content_position_not_relative', node.error('Content position must be relative'))
54
55
  if (name === OVERFLOW && !['auto', 'scroll', 'hidden', 'visible', 'clip'].includes(node.value)) {
55
- throw node.error(`Unsupported overflow value ${node.value}`)
56
+ throw fail('unsupported_overflow', node.error(`Unsupported overflow value ${node.value}`))
56
57
  }
57
58
  if (node.prop === '--content-max-width-align' && !['left', 'center', 'right'].includes(node.value)) {
58
- throw node.error(`Unsupported content alignment ${node.value}`)
59
+ throw fail('unsupported_content_alignment', node.error(`Unsupported content alignment ${node.value}`))
59
60
  }
60
61
  if (node.prop === 'display') {
61
62
  const type = owner.nodes.find(item => item.type === 'decl' && item.prop === '--container-layout-type')
@@ -64,7 +65,7 @@ export const parseTargets = (root, context) => {
64
65
  (type?.type === 'decl' &&
65
66
  (!Object.hasOwn(displays, type.value) || displays[type.value] !== node.value))
66
67
  ) {
67
- throw node.error('Container type and content display are inconsistent')
68
+ throw fail('inconsistent_container_display', node.error('Container type and content display are inconsistent'))
68
69
  }
69
70
  }
70
71
  }
@@ -75,10 +76,10 @@ export const parseTargets = (root, context) => {
75
76
  const seen = new Set()
76
77
  for (const node of owner.nodes) {
77
78
  if (node.type !== 'decl') continue
78
- if (seen.has(node.prop)) throw node.error(`Duplicate root property ${node.prop}`)
79
+ if (seen.has(node.prop)) throw fail('duplicate_root_property', node.error(`Duplicate root property ${node.prop}`))
79
80
  seen.add(node.prop)
80
81
  if ((targets[CONTENT].has(node.prop) && node.prop !== 'position') || targets[OVERFLOW].has(node.prop)) {
81
- throw node.error(`${node.prop} belongs inside @origami-target`)
82
+ throw fail('property_requires_target', node.error(`${node.prop} belongs inside @origami-target`))
82
83
  }
83
84
  }
84
85
  }
@@ -7,8 +7,15 @@ const MAX_CLONED_BYTES = 10 * 1024 * 1024
7
7
  /** @param {import('postcss').AtRule} atRule */
8
8
  const mixinName = atRule => atRule.params.trim().replace(/\(\)$/, '')
9
9
 
10
- /** @type {import('../transform/types.js').CssTransformPlugin} */
10
+ export const checks = /** @type {const} */ (['missing_mixin', 'cyclic_apply', 'expansion_depth_exceeded', 'cloned_node_budget_exceeded', 'cloned_css_budget_exceeded'])
11
+ /** @typedef {typeof checks[number]} Check */
12
+
13
+ /** @template {Error} E @param {Check} check @param {E} error */
14
+ export const fail = (check, error) => Object.assign(error, { check })
15
+
16
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
11
17
  export const mixinsPlugin = {
18
+ checks,
12
19
  name: 'mixins',
13
20
  order: 10,
14
21
  transform(root) {
@@ -30,16 +37,16 @@ export const mixinsPlugin = {
30
37
  container.walkAtRules('apply', atRule => {
31
38
  const name = mixinName(atRule)
32
39
  const body = bodies.get(name)
33
- if (!body) throw new Error(`@apply ${atRule.params} has no @mixin definition`)
34
- if (stack.includes(name)) throw new Error(`Cyclic @apply: ${[...stack, name].join(' -> ')}`)
40
+ if (!body) throw fail('missing_mixin', new Error(`@apply ${atRule.params} has no @mixin definition`))
41
+ if (stack.includes(name)) throw fail('cyclic_apply', new Error(`Cyclic @apply: ${[...stack, name].join(' -> ')}`))
35
42
  if (stack.length >= MAX_EXPANSION_DEPTH) {
36
- throw new Error(`@apply ${name} exceeds maximum expansion depth (${MAX_EXPANSION_DEPTH})`)
43
+ throw fail('expansion_depth_exceeded', new Error(`@apply ${name} exceeds maximum expansion depth (${MAX_EXPANSION_DEPTH})`))
37
44
  }
38
45
  const pending = [...body]
39
46
  while (pending.length) {
40
47
  const node = pending.pop()
41
48
  if (++clonedNodes > MAX_CLONED_NODES) {
42
- throw new Error(`@apply ${name} exceeds stylesheet cloned-node budget (${MAX_CLONED_NODES})`)
49
+ throw fail('cloned_node_budget_exceeded', new Error(`@apply ${name} exceeds stylesheet cloned-node budget (${MAX_CLONED_NODES})`))
43
50
  }
44
51
  if (node && 'nodes' in node && node.nodes) {
45
52
  for (const child of node.nodes) pending.push(child)
@@ -48,7 +55,7 @@ export const mixinsPlugin = {
48
55
  for (const node of body) {
49
56
  clonedBytes += Buffer.byteLength((node.raws.before ?? '') + node.toString(), 'utf8')
50
57
  if (clonedBytes > MAX_CLONED_BYTES) {
51
- throw new Error(`@apply ${name} exceeds stylesheet cloned-CSS byte budget (${MAX_CLONED_BYTES})`)
58
+ throw fail('cloned_css_budget_exceeded', new Error(`@apply ${name} exceeds stylesheet cloned-CSS byte budget (${MAX_CLONED_BYTES})`))
52
59
  }
53
60
  }
54
61
  // PostCSS does not revisit nodes inserted in place of the current at-rule.
@@ -23,8 +23,12 @@ const target = (selector, key) => {
23
23
  return selector === first ? `.${id}${WRAPPER}` : null
24
24
  }
25
25
 
26
- /** @type {import('../transform/types.js').CssTransformPlugin} */
26
+ const checks = /** @type {const} */ ([])
27
+ /** @typedef {typeof checks[number]} Check */
28
+
29
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
27
30
  export const presetsPlugin = {
31
+ checks,
28
32
  name: 'presets',
29
33
  order: 13,
30
34
  transform(root, context) {
@@ -53,7 +57,8 @@ export const presetsPlugin = {
53
57
  declaration,
54
58
  `presets: ${context.relativePath ?? context.filePath}: ` +
55
59
  `${declaration.prop} on "${selectors.join(', ')}": set --preset on #id or .id alone, ` +
56
- `and an inner element's as --preset-{key} on the rule of the element it sits in. Left as authored.`
60
+ `and an inner element's as --preset-{key} on the rule of the element it sits in. Left as authored.`,
61
+ 'unmappable_preset'
57
62
  )
58
63
  continue
59
64
  }
@@ -101,8 +101,12 @@ const layerDeclarations = (compId, vars) => {
101
101
  }
102
102
  }
103
103
 
104
- /** @type {import('../transform/types.js').CssTransformPlugin} */
104
+ const checks = /** @type {const} */ ([])
105
+ /** @typedef {typeof checks[number]} Check */
106
+
107
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
105
108
  export const shapeDividersPlugin = {
109
+ checks,
106
110
  name: 'shape-dividers',
107
111
  // Last, with wix-vector-urls: it only adds rules, which no other plugin needs to see, and it reads only `--height` as
108
112
  // `var(--height)`, so spx converts that var's `--spx()` where it is declared.
@@ -125,7 +129,7 @@ export const shapeDividersPlugin = {
125
129
  const allVars = { ...baseVars.get(id), ...vars }
126
130
  const declarations = layerDeclarations(id, allVars)
127
131
  if (declarations === undefined) {
128
- if (allVars['--svgId']) context.warn?.(rule, `Couldn't draw the shape divider's SVG ${allVars['--svgId']}: it didn't load from Wix's CDN, or has no view box`)
132
+ if (allVars['--svgId']) context.warn?.(rule, `Couldn't draw the shape divider's SVG ${allVars['--svgId']}: it didn't load from Wix's CDN, or has no view box`, 'svg_not_loaded')
129
133
  continue
130
134
  }
131
135
  const layerRule = postcss.rule({ selector: `${rule.selector} ${LAYER}`, source: rule.source })
@@ -1,3 +1,4 @@
1
+ import { fail } from './index.js'
1
2
  const CALL = /--spx\(\s*(-?(?:\d+\.?\d*|\.\d+))\s*\)/g
2
3
 
3
4
  /** Replace literal SPX calls, reporting unsupported arguments at their declaration.
@@ -8,5 +9,5 @@ const CALL = /--spx\(\s*(-?(?:\d+\.?\d*|\.\d+))\s*\)/g
8
9
  export const lowerCalls = (declaration, resolve, invalidCall) => {
9
10
  if (!declaration.value.includes('--spx(')) return
10
11
  declaration.value = declaration.value.replace(CALL, (_, literal) => resolve(Number(literal)))
11
- if (declaration.value.includes('--spx(')) throw invalidCall()
12
+ if (declaration.value.includes('--spx(')) throw fail('non_literal_spx_argument', invalidCall())
12
13
  }
@@ -1,3 +1,4 @@
1
+ import { fail } from './index.js'
1
2
  import { lowerCalls } from './calls.js'
2
3
 
3
4
  // Keep arithmetic at the call site: an inherited derived custom property would already
@@ -16,7 +17,7 @@ const UNCAPPED_UNIT = unitCappedAt('99999px')
16
17
  export const compileCascadeDeclaration = declaration => {
17
18
  if (declaration.prop === '--spx-resolver-type') {
18
19
  const resolver = declaration.value.trim()
19
- if (!['scale', 'fixed'].includes(resolver)) throw declaration.error('Unsupported SPX resolver')
20
+ if (!['scale', 'fixed'].includes(resolver)) throw fail('unsupported_spx_resolver', declaration.error('Unsupported SPX resolver'))
20
21
  const value = resolver === 'scale' ? '1' : '0'
21
22
  const previous = declaration.prev()
22
23
  if (
@@ -32,7 +33,7 @@ export const compileCascadeDeclaration = declaration => {
32
33
  declaration.prop === '--spx-ref-width' &&
33
34
  (!Number.isFinite(Number(declaration.value)) || Number(declaration.value) <= 0)
34
35
  ) {
35
- throw declaration.error('SPX reference width must be positive')
36
+ throw fail('invalid_spx_reference_width', declaration.error('SPX reference width must be positive'))
36
37
  }
37
38
  const unit = declaration.prop === '--content-max-width' ? UNCAPPED_UNIT : UNIT
38
39
  lowerCalls(
@@ -1,7 +1,14 @@
1
1
  import { compileCascadeDeclaration } from './cascade.js'
2
2
 
3
- /** @type {import('../../transform/types.js').CssTransformPlugin} */
3
+ export const checks = /** @type {const} */ (['unsupported_spx_resolver', 'invalid_spx_reference_width', 'non_literal_spx_argument'])
4
+ /** @typedef {typeof checks[number]} Check */
5
+
6
+ /** @template {Error} E @param {Check} check @param {E} error */
7
+ export const fail = (check, error) => Object.assign(error, { check })
8
+
9
+ /** @type {import('../../transform/types.js').CssTransformPlugin<Check>} */
4
10
  export const spxPlugin = {
11
+ checks,
5
12
  name: 'spx',
6
13
  order: 20,
7
14
  transform(root) {
@@ -157,8 +157,12 @@ const where = declaration => {
157
157
 
158
158
  const or = items => (items.length === 1 ? items[0] : `${items.slice(0, -1).join(', ')} or ${items.at(-1)}`)
159
159
 
160
- /** @type {import('../transform/types.js').CssTransformPlugin} */
160
+ export const checks = /** @type {const} */ (['child_not_in_stack'])
161
+ /** @typedef {typeof checks[number]} Check */
162
+
163
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
161
164
  export const stackChildrenPlugin = {
165
+ checks,
162
166
  name: 'stack-children',
163
167
  // After mixins copy their bodies in, before spx rewrites --spx() calls away.
164
168
  order: 12,
@@ -199,7 +203,7 @@ export const stackChildrenPlugin = {
199
203
  }
200
204
 
201
205
  if (violations.size) {
202
- throw new LayoutError([...violations], {
206
+ throw new LayoutError(stackChildrenPlugin, [...violations].map(message => ({ check: 'child_not_in_stack', message })), {
203
207
  file: first?.input.file,
204
208
  line: first?.start?.line,
205
209
  column: first?.start?.column,
@@ -16,13 +16,16 @@ const loading = new Map()
16
16
  const svgFileName = id => (id.endsWith('.svg') ? id : `${id}.svg`)
17
17
 
18
18
  const load = async id => {
19
+ let ok = false
19
20
  try {
20
21
  const response = await fetch(SHAPES_URL + svgFileName(id), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
21
22
  const svg = response.ok ? await response.text() : ''
22
- loaded.set(id, SVG_TAG.test(svg) ? svg : null)
23
+ ok = SVG_TAG.test(svg)
24
+ loaded.set(id, ok ? svg : null)
23
25
  } catch {
24
26
  // Not loaded: the plugin warns and keeps the value as written.
25
27
  }
28
+ return ok
26
29
  }
27
30
 
28
31
  /** Loads the SVGs of `ids` that aren't loaded yet. */
@@ -33,7 +36,7 @@ export const loadSvgs = ids => {
33
36
  if (!loading.has(id)) loading.set(id, load(id).finally(() => loading.delete(id)))
34
37
  return loading.get(id)
35
38
  })
36
- return pending.length ? Promise.all(pending).then(() => undefined) : undefined
39
+ return pending.length ? Promise.all(pending).then(results => ({ fetched: results.length, failed: results.filter(ok => !ok).length })) : undefined
37
40
  }
38
41
 
39
42
  /** The SVG's markup, once `loadSvgs` loaded it. */
@@ -17,8 +17,12 @@ const idsIn = root => {
17
17
  return ids
18
18
  }
19
19
 
20
- /** @type {import('../transform/types.js').CssTransformPlugin} */
20
+ const checks = /** @type {const} */ ([])
21
+ /** @typedef {typeof checks[number]} Check */
22
+
23
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
21
24
  export const wixVectorUrlsPlugin = {
25
+ checks,
22
26
  name: 'wix-vector-urls',
23
27
  // Last, so a url a mixin or preset copies elsewhere is inlined wherever it ends up.
24
28
  order: 25,
@@ -29,7 +33,7 @@ export const wixVectorUrlsPlugin = {
29
33
  declaration.value = declaration.value.replace(WIX_VECTOR_URL, (url, _quote, id) => {
30
34
  const svg = loadedSvg(id)
31
35
  if (svg === undefined) {
32
- context.warn?.(declaration, `Couldn't load the SVG ${id} from Wix's CDN, so ${url} stays as written`)
36
+ context.warn?.(declaration, `Couldn't load the SVG ${id} from Wix's CDN, so ${url} stays as written`, 'svg_not_loaded')
33
37
  return url
34
38
  }
35
39
  return svgDataUrl(scaleSvg(svg, svgInfo(svg), 'none'))
@@ -7,5 +7,5 @@ import { origamiCompatibilityPlugin } from './origamiCompatibility/index.js'
7
7
  import { layoutValidationPlugin } from './validation/index.js'
8
8
  import { pageSlotsPlugin } from './pageSlots.js'
9
9
 
10
- /** @type {readonly import('../transform/types.js').JsxTransformPlugin[]} */
10
+ /** @type {readonly import('../transform/types.js').JsxTransformPlugin<string>[]} */
11
11
  export const jsxPlugins = Object.freeze([layoutValidationPlugin, mediaValidationPlugin, origamiCompatibilityPlugin, pageSlotsPlugin])
@@ -1,3 +1,4 @@
1
+ import { warn } from '../../transform/bi.js'
1
2
  // Fails the build on an Image whose `uri` is not a Wix media ID, such as a web URL or a local file.
2
3
  // The Wix import accepts such a `uri` but the image is broken on the site; `wix origami upload-media`
3
4
  // uploads it and points the Image at the uploaded file. `yarn dev` only warns, so the page still
@@ -12,8 +13,12 @@ const STRUCTURE_FILES = new Set(['structure.tsx', 'structure.jsx'])
12
13
  // `117a96_1cff23187eeb4050a2a691bcdcad6502~mv2.jpg`: an account prefix, an underscore and a file name.
13
14
  const WIX_MEDIA_ID = /^[A-Za-z0-9]+_[A-Za-z0-9_~.-]+\.[A-Za-z0-9]+$/
14
15
 
15
- /** @type {import('../../transform/types.js').JsxTransformPlugin} */
16
+ export const checks = /** @type {const} */ (['media_not_wix'])
17
+ /** @typedef {typeof checks[number]} Check */
18
+
19
+ /** @type {import('../../transform/types.js').JsxTransformPlugin<Check>} */
16
20
  export const mediaValidationPlugin = {
21
+ checks,
17
22
  name: 'wix-import-media-validation',
18
23
  // Alongside the layout rules: before anything rewrites the tree.
19
24
  order: -100,
@@ -21,6 +26,7 @@ export const mediaValidationPlugin = {
21
26
 
22
27
  transform: (program, context) => {
23
28
  const file = context.relativePath ?? context.filePath
29
+ /** @type {import('../../transform/types.js').Violation<Check>[]} */
24
30
  const violations = []
25
31
  let first
26
32
  program.traverse({
@@ -33,17 +39,20 @@ export const mediaValidationPlugin = {
33
39
  if (uri?.value.type !== 'StringLiteral' || WIX_MEDIA_ID.test(uri.value.value)) return
34
40
  const id = attributes.find(attribute => attribute.name.name === 'id')?.value?.value
35
41
  const line = uri.loc?.start.line
36
- violations.push(
42
+ violations.push({ check: 'media_not_wix', message:
37
43
  `${file}${line ? `:${line}` : ''} <Image${id ? ` id="${id}"` : ''}> image.uri "${uri.value.value}" is not a Wix media ID. Run \`wix origami upload-media\` to upload it.`
38
- )
44
+ })
39
45
  first ??= { file: context.filePath, line, column: uri.loc?.start.column }
40
46
  },
41
47
  })
42
48
  if (violations.length === 0) return
43
49
  if (context.command === 'serve') {
44
- for (const violation of violations) console.warn(`[origami] ${violation}`)
50
+ for (const violation of violations) {
51
+ warn('media_not_wix')
52
+ console.warn(`[origami] ${violation.message}`)
53
+ }
45
54
  return
46
55
  }
47
- throw new LayoutError(violations, first)
56
+ throw new LayoutError(mediaValidationPlugin, violations, first)
48
57
  },
49
58
  }
@@ -22,8 +22,12 @@ const RULES_BY_COMPONENT = new Map(
22
22
  ])
23
23
  )
24
24
 
25
- /** @type {import('../../transform/types.js').JsxTransformPlugin} */
25
+ const checks = /** @type {const} */ ([])
26
+ /** @typedef {typeof checks[number]} Check */
27
+
28
+ /** @type {import('../../transform/types.js').JsxTransformPlugin<Check>} */
26
29
  export const origamiCompatibilityPlugin = {
30
+ checks,
27
31
  name: 'wix-import-origami-compatibility',
28
32
  order: 0,
29
33
  appliesTo: context => basename(context.filePath) === STRUCTURE_FILE,
@@ -32,8 +32,13 @@ const slotFor = component => {
32
32
  return undefined
33
33
  }
34
34
 
35
- /** Transitional support for DM's literal Page children alongside authored slot props. */
35
+ const checks = /** @type {const} */ ([])
36
+ /** @typedef {typeof checks[number]} Check */
37
+
38
+ /** Transitional support for DM's literal Page children alongside authored slot props.
39
+ * @type {import('../transform/types.js').JsxTransformPlugin<Check>} */
36
40
  export const pageSlotsPlugin = {
41
+ checks,
37
42
  name: 'wix-import-page-slots',
38
43
  order: 0,
39
44
  appliesTo: context => basename(context.filePath) === 'structure.jsx',
@@ -75,7 +75,7 @@ const CHECKS = {
75
75
  export const assertKnownConstraints = rules => {
76
76
  for (const rule of rules) {
77
77
  for (const key of Object.keys(rule)) {
78
- if (key !== 'id' && key !== 'components' && !CHECKS[key]) {
78
+ if (key !== 'id' && key !== 'components' && key !== 'checks' && !CHECKS[key]) {
79
79
  throw new Error(
80
80
  `layout rules: unknown constraint "${key}" on ${rule.id}. Known: ${Object.keys(CHECKS).join(', ')}`
81
81
  )
@@ -86,20 +86,27 @@ export const assertKnownConstraints = rules => {
86
86
 
87
87
  export const constrainsChildren = rule => CHILD_CONSTRAINTS.some(key => key in rule)
88
88
 
89
- /** Runs every constraint of `rule` on one element; `report` receives each full message. */
89
+ /** Runs every constraint of `rule` on one element; `report` receives each full message.
90
+ * @param {import('./rules.js').Rule} rule
91
+ * @param {{ node: any, componentOf: (node: any) => string | undefined, report: (check: import('./index.js').Check, message: string) => void }} input
92
+ */
90
93
  export const checkRule = (rule, { node, componentOf, report }) => {
91
94
  const prefix = `${describe(node)} ${expectation(rule)}`
92
- const fail = got => report(`${prefix}; found ${got}.`)
95
+ /** @param {import('./index.js').Check} check */
96
+ const fail = check => got => report(check, `${prefix}; found ${got}.`)
93
97
 
94
98
  const elements = node.children
95
99
  .filter(child => child.type === 'JSXElement')
96
100
  .map(element => ({ element, component: componentOf(element) }))
97
- const context = { node, elements, fail }
101
+ const context = { node, elements }
98
102
 
99
- if (constrainsChildren(rule)) checkLiteral(context)
100
- for (const [key, value] of Object.entries(rule)) CHECKS[key]?.(value, context)
103
+ if (constrainsChildren(rule)) checkLiteral({ ...context, fail: fail(rule.checks.literal) })
104
+ for (const [key, value] of Object.entries(rule)) CHECKS[key]?.(value, { ...context, fail: fail(rule.checks[key]) })
101
105
  }
102
106
 
103
- /** `createElement(Section, …)` would hide a constrained component's children from the checks. */
107
+ /** `createElement(Section, …)` would hide a constrained component's children from the checks.
108
+ * @param {import('./rules.js').Rule} rule
109
+ * @param {{ node: any, component: string, report: (check: import('./index.js').Check, message: string) => void }} input
110
+ */
104
111
  export const checkCreateElement = (rule, { node, component, report }) =>
105
- report(`<${component}>${at(node)} ${expectation(rule)}; found createElement(${component}), write it as a JSX tag.`)
112
+ report(rule.checks.createElement, `<${component}>${at(node)} ${expectation(rule)}; found createElement(${component}), write it as a JSX tag.`)
@@ -26,8 +26,12 @@ const isCreateElement = callee =>
26
26
  (callee.type === 'Identifier' && callee.name === 'createElement') ||
27
27
  (callee.type === 'MemberExpression' && !callee.computed && callee.property.name === 'createElement')
28
28
 
29
- /** @type {import('../../transform/types.js').JsxTransformPlugin} */
29
+ export const checks = /** @type {const} */ (['section_wrong_child', 'section_too_few_children', 'section_too_many_children', 'section_non_literal_children', 'section_create_element'])
30
+ /** @typedef {typeof checks[number]} Check */
31
+
32
+ /** @type {import('../../transform/types.js').JsxTransformPlugin<Check>} */
30
33
  export const layoutValidationPlugin = {
34
+ checks,
31
35
  name: 'wix-import-layout-validation',
32
36
  // Before any plugin that rewrites the tree, so the rules see it as authored.
33
37
  order: -100,
@@ -40,8 +44,10 @@ export const layoutValidationPlugin = {
40
44
  return name.type === 'JSXIdentifier' ? name.name : undefined
41
45
  }
42
46
 
47
+ /** @type {import('../../transform/types.js').Violation<Check>[]} */
43
48
  const violations = []
44
- const report = message => violations.push(message)
49
+ /** @param {Check} check @param {string} message */
50
+ const report = (check, message) => violations.push({ check, message })
45
51
  program.traverse({
46
52
  JSXElement(path) {
47
53
  const component = componentOf(path.node)
@@ -60,6 +66,6 @@ export const layoutValidationPlugin = {
60
66
  },
61
67
  })
62
68
 
63
- if (violations.length) throw new LayoutError(violations)
69
+ if (violations.length) throw new LayoutError(layoutValidationPlugin, violations)
64
70
  },
65
71
  }
@@ -13,9 +13,23 @@
13
13
 
14
14
  const STANDARD_SECTIONS = ['Section', 'HeaderSection', 'FooterSection']
15
15
 
16
+ /**
17
+ * @typedef {object} Rule
18
+ * @property {string} id
19
+ * @property {string[]} components
20
+ * @property {string[]} allowedChildren
21
+ * @property {number} minChildren
22
+ * @property {number} maxChildren
23
+ * @property {Record<'allowedChildren' | 'minChildren' | 'maxChildren' | 'literal' | 'createElement', import('./index.js').Check>} checks
24
+ */
25
+ /** @satisfies {Rule[]} */
16
26
  export const RULES = [
17
27
  {
18
28
  id: 'S2-SECTION-001',
29
+ checks: {
30
+ allowedChildren: 'section_wrong_child', minChildren: 'section_too_few_children',
31
+ maxChildren: 'section_too_many_children', literal: 'section_non_literal_children', createElement: 'section_create_element',
32
+ },
19
33
  components: STANDARD_SECTIONS,
20
34
  allowedChildren: ['InternalContainer'],
21
35
  minChildren: 1,
@@ -0,0 +1,64 @@
1
+ import { currentRow } from '@wix/origami-bi'
2
+ import { LayoutError } from './layoutError.js'
3
+ const summaries = new WeakMap()
4
+
5
+ /** Aggregate completed dev invocations; paths are only in this local set, never in rows. */
6
+ export function countPlugin(context, duration) {
7
+ const parent = currentRow()
8
+ if (!parent || context.command !== 'serve') return
9
+ const { run } = parent
10
+ let summary = summaries.get(run)
11
+ if (!summary) {
12
+ summary = { files: new Set(), plugins: 0, duration_ms: 0 }
13
+ summaries.set(run, summary)
14
+ const timer = setInterval(() => {
15
+ if (!run.commandRow.isOpen) { clearInterval(timer); summaries.delete(run); return }
16
+ if (!summary.plugins) return
17
+ run.commandRow.runInContext(() => run.openRow('step', 'transform_summary', {
18
+ step_details: { files: summary.files.size, plugins: summary.plugins, duration_ms: Math.round(summary.duration_ms) },
19
+ }).end())
20
+ summary.files.clear(); summary.plugins = 0; summary.duration_ms = 0
21
+ }, 60_000)
22
+ timer.unref()
23
+ }
24
+ summary.files.add(context.filePath)
25
+ summary.plugins++
26
+ summary.duration_ms += duration
27
+ }
28
+
29
+ /** @param {'malformed_custom_enum' | 'unmappable_preset' | 'svg_not_loaded' | 'media_not_wix'} key */
30
+ export function warn(key) {
31
+ const current = currentRow()
32
+ // A passing dev plugin row is discarded; warnings must retain a live parent.
33
+ const parent = current?.step_type === 'build_plugin' ? current.parent : current
34
+ parent?.runInContext(() => parent.run.openRow('warning', key, { error_name: key }).end())
35
+ }
36
+
37
+ /**
38
+ * @template {string} Check
39
+ * @param {import('./types.js').TransformPluginBase<Check>} plugin
40
+ * @param {unknown} cause
41
+ * @returns {Check | undefined}
42
+ */
43
+ export function declaredCheck(plugin, cause) {
44
+ return cause instanceof Error && 'check' in cause ? plugin.checks.find(check => check === cause.check) : undefined
45
+ }
46
+
47
+ /** Finish a plugin failure, projecting only its declared keys into check children. */
48
+ export function recordPluginFailure(plugin, cause, pageId) {
49
+ const row = currentRow()
50
+ const check = declaredCheck(plugin, cause)
51
+ const violations = cause instanceof LayoutError ? cause.violations :
52
+ check !== undefined ? [{ check }] : []
53
+ const layout = cause instanceof LayoutError || violations.length > 0
54
+ row?.end(false, { error_name: layout ? 'layout_error' : 'plugin_error' }, {
55
+ outcome: layout ? 'layout_error' : 'plugin_error', violations: violations.length,
56
+ })
57
+ const counts = new Map()
58
+ for (const { check } of violations) {
59
+ if (plugin.checks.includes(check)) counts.set(check, (counts.get(check) ?? 0) + 1)
60
+ }
61
+ for (const [check, violations] of counts) row?.run.recordRow('build_plugin', plugin.name, {
62
+ ...(pageId ? { item_type: 'page', item_id: pageId } : {}), step_details: { check, violations },
63
+ }, { success: false, duration_ms: 0 }, row)
64
+ }
@@ -1,5 +1,44 @@
1
+ import { currentRow } from '@wix/origami-bi'
2
+ import { existsSync, readFileSync, statSync } from 'node:fs'
3
+ import { dirname, join, relative } from 'node:path'
4
+ import { parse } from '@babel/parser'
1
5
  import { relativeToSite } from './relativeToSite.js'
2
6
 
7
+ const identities = new Map()
8
+ export function pageIdFor(context) {
9
+ if (!currentRow()?.run.enabled || !context.layout?.root) return undefined
10
+ const { root } = context.layout
11
+ const file = context.filePath
12
+ let folder = dirname(file)
13
+ const pages = join(root, 'src/pages')
14
+ while (relative(pages, folder) && !relative(pages, folder).startsWith('..')) {
15
+ const structure = ['structure.jsx', 'structure.tsx'].map(name => join(folder, name)).find(existsSync)
16
+ if (structure) {
17
+ try {
18
+ const modified = statSync(structure).mtimeMs
19
+ if (identities.get(structure)?.modified === modified) return identities.get(structure).id
20
+ const ast = parse(readFileSync(structure, 'utf8'), { sourceType: 'module', plugins: ['jsx', 'typescript'] })
21
+ let id
22
+ const visit = node => {
23
+ if (!node || typeof node !== 'object') return
24
+ if (node.type === 'JSXOpeningElement' && node.name?.name === 'Page') {
25
+ const attribute = node.attributes.find(value => value.type === 'JSXAttribute' && value.name.name === 'id')
26
+ if (attribute?.value?.type === 'StringLiteral') id ??= attribute.value.value
27
+ }
28
+ for (const value of Object.values(node)) {
29
+ if (Array.isArray(value)) value.forEach(visit)
30
+ else if (value && typeof value === 'object') visit(value)
31
+ }
32
+ }
33
+ visit(ast)
34
+ identities.set(structure, { modified, id })
35
+ return id
36
+ } catch { return undefined }
37
+ }
38
+ folder = dirname(folder)
39
+ }
40
+ }
41
+
3
42
  export const createContextFactory =
4
43
  ({ layout, command }) =>
5
44
  (id, { ssr = false } = {}) =>