mosaic-headless 1.26.1 → 1.28.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.
@@ -95,7 +95,7 @@ class Surface:
95
95
  self.default_children = {r["type"]: r["default_children"].split("|")
96
96
  for r in load_csv("default-children.csv")}
97
97
 
98
- def check(self, parent_type, node, force):
98
+ def check(self, parent_type, node, force, ancestors=()):
99
99
  t = node["type"]
100
100
  problems = []
101
101
  if t not in self.types:
@@ -157,12 +157,23 @@ class Surface:
157
157
  # registered types - slider/slider-slides, accordion/accordion-item,
158
158
  # form/form-input - and which actually carries one of them is not the thing
159
159
  # that was measured, and the verdict does not apply to it.
160
+ rule_for_t = self.rules.get(t, {})
160
161
  family = [x for x in self.types if x.startswith(t + "-")]
161
162
  kids = [c.get("type") for c in (node.get("children") or []) if isinstance(c, dict)]
162
163
  nested_as_family = bool(family) and any(k in family for k in kids)
163
164
 
165
+ # `nested_under` is what `canBeNestedChildFor()` walks the parent chain
166
+ # looking for: `radio-input` needs a `choice` above it, `label` needs a
167
+ # `field`, `loop-pagination` needs a `loop`. The names often share no
168
+ # prefix, so this is the only way to tell a correct placement from the
169
+ # bare one that was measured.
170
+ needs = (rule_for_t.get("nested_under") or "").split("|") if rule_for_t else []
171
+ nested_ok = bool(needs and needs != [""] and
172
+ any(x in needs for x in (ancestors + ((parent_type,)
173
+ if parent_type else ()))))
174
+
164
175
  outcome = self.outcome.get(t)
165
- if (not declared_child and not nested_as_family
176
+ if (not declared_child and not nested_as_family and not nested_ok
166
177
  and (outcome == "BROKE_PAGE" or (outcome or "").startswith("COMMIT_5"))):
167
178
  problems.append("%s is measured %s under a plain container, and %s is not its declared parent"
168
179
  % (t, outcome, parent_type or "<root>"))
@@ -184,6 +195,19 @@ class Surface:
184
195
  problems.append("%s is a leaf and accepts no children (tried %s)" % (parent_type, t))
185
196
  elif rule.get("rule") == "allow":
186
197
  allowed = rule["allowed_children"].split("|") + self.default_children.get(parent_type, [])
198
+ # A loop container stands in for its parent's children: a
199
+ # `slider-loop-slides` sits inside `slider-slides` and repeats a
200
+ # `slider-slide`, so what it accepts is what `slider-slides`
201
+ # accepts. The plugin's own canBeParentFor says `slider-slides`
202
+ # here, which would nest the track inside its own loop - and the
203
+ # factory's default children say `slider-slide`, which is the
204
+ # structure the editor builds. Rather than hard-code the
205
+ # disagreement, take the rule from the grandparent, which is both
206
+ # types' actual authority.
207
+ # ancestors[-1] IS the parent; the grandparent is one further up
208
+ if t not in allowed and "-loop-" in parent_type and len(ancestors) > 1:
209
+ gp = self.rules.get(ancestors[-2], {})
210
+ allowed = allowed + (gp.get("allowed_children") or "").split("|")
187
211
  if t not in allowed:
188
212
  problems.append("%s accepts only %s, not %s" % (parent_type, "/".join(allowed), t))
189
213
  # customStyles is the pre-1.0.8 name of customDeclarations; expand_shorthands maps it
@@ -312,12 +336,13 @@ def refresh_node_ids(tree):
312
336
  return json.loads(blob)
313
337
 
314
338
 
315
- def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_type=None, out=None):
339
+ def flatten(node, parent_id, master_id, surface, force, ordering="a0",
340
+ parent_type=None, out=None, ancestors=()):
316
341
  """Depth-first walk producing revision records, checking placement as it goes."""
317
342
  out = out if out is not None else []
318
343
  if node.get("component"):
319
344
  node = dict(node, type="div") # placement is checked as a plain element
320
- problems = surface.check(parent_type, node, force)
345
+ problems = surface.check(parent_type, node, force, ancestors)
321
346
  if problems:
322
347
  raise SystemExit("refusing to build:\n " + "\n ".join(problems))
323
348
 
@@ -360,7 +385,8 @@ def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_ty
360
385
  if node.get("text") is not None:
361
386
  children.insert(0, {"type": "wysiwyg-text", "data": {"text": node["text"]}})
362
387
  for i, child in enumerate(children):
363
- flatten(child, nid, master_id, surface, force, ordering_for(i), node["type"], out)
388
+ flatten(child, nid, master_id, surface, force, ordering_for(i),
389
+ node["type"], out, ancestors + (node["type"],))
364
390
  return out
365
391
 
366
392
 
@@ -69,7 +69,13 @@ def extract(plugin_root, out_dir):
69
69
  sys.exit("no Mosaic/NodeTypes under %s" % plugin_root)
70
70
 
71
71
  types = []
72
- for path in sorted(walk(node_root, ("TypeFactory.php",))):
72
+ # `*MResourceFactory.php` as well as `*TypeFactory.php`: two node types -
73
+ # `fieldset-legend` and `fieldset-content` - are declared only by the former,
74
+ # so a walk of TypeFactory alone leaves them out of the catalog entirely. A
75
+ # page that needs them is then refused by the placement guard as an "unknown
76
+ # node type", and a reader of this table is told the fieldset has no inner
77
+ # structure, which is how a form came back with its fields dropped.
78
+ for path in sorted(walk(node_root, ("TypeFactory.php", "MResourceFactory.php"))):
73
79
  src = read(path)
74
80
  cls = RE_CLASS.search(src)
75
81
  ctor = RE_CTOR.search(src)
@@ -57,7 +57,8 @@ def read(path):
57
57
  def walk(root, suffix):
58
58
  for dirpath, _dirs, files in os.walk(root):
59
59
  for fn in sorted(files):
60
- if fn.endswith(suffix):
60
+ if fn.endswith(suffix) if isinstance(suffix, str) else any(
61
+ fn.endswith(x) for x in suffix):
61
62
  yield os.path.join(dirpath, fn)
62
63
 
63
64
 
@@ -68,7 +69,7 @@ def extract(plugin_root, out_dir):
68
69
 
69
70
  # factory class name -> type slug, so instanceof targets can be named as types
70
71
  class_to_type, sources = {}, {}
71
- for path in walk(node_root, "TypeFactory.php"):
72
+ for path in walk(node_root, ("TypeFactory.php", "MResourceFactory.php")):
72
73
  src = read(path)
73
74
  cls = RE_CLASS.search(src)
74
75
  if not cls:
@@ -95,6 +96,18 @@ def extract(plugin_root, out_dir):
95
96
  body, defined_in = resolve(cls_name)
96
97
  path, src, _parent = sources[cls_name]
97
98
  nested = "yes" if "function canBeNestedChildFor" in src else ""
99
+ # `yes` alone says a runtime ancestry rule exists and not what it wants.
100
+ # These methods walk the parent chain looking for one MResource class -
101
+ # `radio-input` wants a `ChoiceElementMResource` above it - and that is
102
+ # the one fact a caller needs to know whether a placement is legal.
103
+ nested_under = []
104
+ if nested:
105
+ mbody = re.search(
106
+ r"function\s+canBeNestedChildFor\s*\([^)]*\)\s*:\s*bool\s*\{(.*?)\n \}",
107
+ src, re.S)
108
+ if mbody:
109
+ for cls in re.findall(r"instanceof\s+(\w+)ElementMResource\b", mbody.group(1)):
110
+ nested_under.append(cls)
98
111
 
99
112
  if body is None:
100
113
  rule, allowed = "none", [] # inherited default on the abstract base
@@ -121,6 +134,12 @@ def extract(plugin_root, out_dir):
121
134
  "allowed_children": "|".join(allowed),
122
135
  "nested_rule": nested,
123
136
  "takes_children": "yes" if RE_TAKES_CHILDREN.search(src) else "",
137
+ # `class_to_type` is keyed by the FACTORY class, and what the
138
+ # method names is the MResource - `ChoiceElementMResource` is
139
+ # declared by `ChoiceElementTypeFactory`.
140
+ "nested_under": "|".join(sorted(set(
141
+ class_to_type.get(c + "ElementTypeFactory", "")
142
+ for c in nested_under) - {""})),
124
143
  "declared_in": os.path.relpath(defined_in or path, plugin_root).replace(os.sep, "/"),
125
144
  }
126
145
  )
@@ -128,7 +147,8 @@ def extract(plugin_root, out_dir):
128
147
  os.makedirs(out_dir, exist_ok=True)
129
148
  with open(os.path.join(out_dir, "placement-rules.csv"), "w", newline="", encoding="utf-8") as fh:
130
149
  w = csv.DictWriter(fh, fieldnames=["type", "rule", "allowed_children", "nested_rule",
131
- "takes_children", "declared_in"])
150
+ "takes_children", "nested_under",
151
+ "declared_in"])
132
152
  w.writeheader()
133
153
  w.writerows(rows)
134
154
 
@@ -46,6 +46,13 @@ def main():
46
46
  ap.add_argument("--faq", default="cp-faq-q-%d/cp-faq-a-%d:4")
47
47
  ap.add_argument("--form", default="cp-form")
48
48
  ap.add_argument("--map", default="cp-map")
49
+ ap.add_argument("--loop-slider", default="ct-slider",
50
+ help="a slider whose slides come from a loop")
51
+ ap.add_argument("--loop-tabs", default="ct-tabs-menu")
52
+ ap.add_argument("--pagination", default="ct-arch-nums",
53
+ help="a loop-pagination-numbers whose children are page links")
54
+ ap.add_argument("--paged-item", default="ct-arch-title",
55
+ help="the repeated attrID whose text should change per page")
49
56
  a = ap.parse_args()
50
57
 
51
58
  rows, fails = [], []
@@ -56,6 +63,18 @@ def main():
56
63
  if not ok:
57
64
  fails.append(name)
58
65
 
66
+ def skip(name, why):
67
+ """A page that does not carry a component is not a page that broke it.
68
+
69
+ The same tool runs against every showcase page, so "absent" has to be a
70
+ third outcome - otherwise either the form page fails on tabs it never
71
+ had, or the checks get a flag per component and nobody runs them."""
72
+ rows.append([name, "SKIPPED", why])
73
+ print(" %-28s %-5s %s" % (name, "SKIP", why))
74
+
75
+ def present(el_id):
76
+ return p.evaluate("(id) => !!document.getElementById(id)", el_id)
77
+
59
78
  def split(spec):
60
79
  pat, n = spec.rsplit(":", 1)
61
80
  tab, pane = pat.split("/")
@@ -71,64 +90,148 @@ def main():
71
90
  p.wait_for_timeout(2200)
72
91
 
73
92
  # ---- tabs
74
- shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
75
- check("TABS/one_pane_at_rest", shown == [0],
76
- "visible panes at rest: %s" % shown)
77
- last = n_tabs - 1
78
- p.click("#" + tab_pat % last)
79
- p.wait_for_timeout(600)
80
- shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
81
- check("TABS/click_switches", shown == [last],
82
- "clicked tab %d -> panes %s" % (last, shown))
83
- active = p.evaluate("""() => {
84
- const t = document.querySelector('.m-tab--active');
85
- if (!t) return null;
86
- const ps = [...t.querySelectorAll('p')].map(e => getComputedStyle(e).color);
87
- return {bg: getComputedStyle(t).backgroundColor, labels: ps};
88
- }""")
89
- # the active tab is darkened by the plugin; if the label was not restyled
90
- # with ___tab--active___descendants it is ink on ink and unreadable
91
- readable = bool(active) and active["bg"] != (active["labels"] or [""])[0]
92
- check("TABS/active_label_readable", readable, str(active))
93
+ if not present(tab_pat % 0):
94
+ skip("TABS", "not on this page")
95
+ else:
96
+ shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
97
+ check("TABS/one_pane_at_rest", shown == [0],
98
+ "visible panes at rest: %s" % shown)
99
+ last = n_tabs - 1
100
+ p.click("#" + tab_pat % last)
101
+ p.wait_for_timeout(600)
102
+ shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
103
+ check("TABS/click_switches", shown == [last],
104
+ "clicked tab %d -> panes %s" % (last, shown))
105
+ active = p.evaluate("""() => {
106
+ const t = document.querySelector('.m-tab--active');
107
+ if (!t) return null;
108
+ const ps = [...t.querySelectorAll('p')].map(e => getComputedStyle(e).color);
109
+ return {bg: getComputedStyle(t).backgroundColor, labels: ps};
110
+ }""")
111
+ # the plugin darkens the active tab; a label that was not restyled
112
+ # with ___tab--active___descendants is then ink on ink
113
+ readable = bool(active) and active["bg"] != (active["labels"] or [""])[0]
114
+ check("TABS/active_label_readable", readable, str(active))
93
115
 
94
116
  # ---- accordion
95
- open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
96
- check("ACCORDION/shut_at_rest", open_now == [], "open: %s" % open_now)
97
- p.click("#" + q_pat % 1)
98
- p.wait_for_timeout(700)
99
- open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
100
- check("ACCORDION/click_opens_one", open_now == [1],
101
- "clicked 1 -> open %s" % open_now)
102
- tabindex = p.evaluate("(id) => document.getElementById(id).getAttribute('tabindex')",
103
- q_pat % 1)
104
- check("ACCORDION/keyboard_reachable", tabindex == "0",
105
- "title tabindex=%r - the plugin puts it in the tab order" % tabindex)
117
+ if not present(q_pat % 0):
118
+ skip("ACCORDION", "not on this page")
119
+ else:
120
+ open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
121
+ check("ACCORDION/shut_at_rest", open_now == [], "open: %s" % open_now)
122
+ p.click("#" + q_pat % 1)
123
+ p.wait_for_timeout(700)
124
+ open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
125
+ check("ACCORDION/click_opens_one", open_now == [1],
126
+ "clicked 1 -> open %s" % open_now)
127
+ ti = p.evaluate("(id) => document.getElementById(id).getAttribute('tabindex')",
128
+ q_pat % 1)
129
+ check("ACCORDION/keyboard_reachable", ti == "0",
130
+ "title tabindex=%r - the plugin puts it in the tab order" % ti)
106
131
 
107
132
  # ---- form
108
- fields = p.evaluate("""(id) => [...document.querySelectorAll(
109
- '#' + id + ' input, #' + id + ' textarea')].map(
110
- e => ({name: e.getAttribute('name'), req: e.required,
111
- tag: e.tagName.toLowerCase()}))""", a.form)
112
- named = [f["name"] for f in fields if f["name"]]
113
- honeypot = [n for n in named if n.startswith("mosaic_hp")]
114
- real = [n for n in named if not n.startswith("mosaic_hp")]
115
- check("FORM/fields", len(real) >= 3, "named fields: %s" % real)
116
- check("FORM/required", all(f["req"] for f in fields
117
- if f["name"] in real),
118
- "every author field is required")
119
- check("FORM/honeypot", bool(honeypot),
120
- "Mosaic adds its own spam trap: %s" % honeypot)
133
+ if not present(a.form):
134
+ skip("FORM", "not on this page")
135
+ else:
136
+ fields = p.evaluate("""(id) => [...document.querySelectorAll(
137
+ '#' + id + ' input, #' + id + ' select, #' + id + ' textarea')].map(
138
+ e => ({name: e.getAttribute('name'), req: e.required,
139
+ tag: e.tagName.toLowerCase(), type: e.type}))""", a.form)
140
+ named = [f["name"] for f in fields if f["name"]]
141
+ honeypot = [n for n in named if n.startswith("mosaic_hp")]
142
+ real = [f for f in fields if f["name"] and not f["name"].startswith("mosaic_hp")]
143
+ kinds = sorted({f["type"] for f in real})
144
+ check("FORM/fields", len(real) >= 3,
145
+ "%d controls, types: %s" % (len(real), ", ".join(kinds)))
146
+ check("FORM/required", any(f["req"] for f in real),
147
+ "required is set on %d of them" % sum(1 for f in real if f["req"]))
148
+ check("FORM/honeypot", bool(honeypot),
149
+ "Mosaic adds its own spam trap: %s" % honeypot)
150
+ # a <select> whose options have no text renders as a blank dropdown:
151
+ # `select-option` is a LEAF and its label is the `text` PROPERTY
152
+ opts = p.evaluate("""(id) => [...document.querySelectorAll('#' + id + ' option')]
153
+ .map(o => o.textContent.trim())""", a.form)
154
+ if opts:
155
+ check("FORM/option_labels", all(opts),
156
+ "%d options, all labelled" % len(opts) if all(opts)
157
+ else "blank labels - `text` is a property, not a child")
158
+ else:
159
+ skip("FORM/option_labels", "no select on this form")
160
+ # the browser's own validation has to stop an empty required form
161
+ p.evaluate("(id) => document.getElementById(id).reset()", a.form)
162
+ invalid = p.evaluate("""(id) => [...document.getElementById(id)
163
+ .querySelectorAll(':invalid')].filter(e => e.name).map(e => e.name)""",
164
+ a.form)
165
+ check("FORM/required_blocks", bool(invalid),
166
+ "empty form reports invalid: %s" % invalid[:4])
167
+
168
+ # ---- components whose children come from a loop
169
+ #
170
+ # One authored template, repeated per row. The assertion is COUNT: a
171
+ # template that failed to loop renders exactly once and looks fine.
172
+ if not present(a.loop_slider):
173
+ skip("LOOP/slider", "not on this page")
174
+ else:
175
+ n = p.evaluate("""(id) => document.querySelectorAll(
176
+ '#' + id + ' mosaic-slider-slide').length""", a.loop_slider)
177
+ check("LOOP/slider", n > 1,
178
+ "%d slides from one authored slider-slide" % n)
179
+ if not present(a.loop_tabs):
180
+ skip("LOOP/tabs", "not on this page")
181
+ else:
182
+ n = p.evaluate("""(id) => document.querySelectorAll(
183
+ '#' + id + ' .m-tab').length""", a.loop_tabs)
184
+ labels = p.evaluate("""(id) => [...document.querySelectorAll(
185
+ '#' + id + ' .m-tab')].map(e => e.textContent.trim())""", a.loop_tabs)
186
+ check("LOOP/tabs", n > 1, "%d tabs from one authored tabs-tab" % n)
187
+ # @substr counts BYTES; a cut through a multi-byte character leaves
188
+ # U+FFFD, which is invisible in a count and obvious to a reader
189
+ check("LOOP/tab_labels_intact",
190
+ all("�" not in x for x in labels),
191
+ "no replacement characters - @substr counts bytes, so a length "
192
+ "that is not a multiple of the encoding cuts a glyph in half")
193
+
194
+ # ---- pagination actually pages
195
+ if not present(a.pagination):
196
+ skip("LOOP/pagination", "not on this page")
197
+ else:
198
+ def titles():
199
+ return p.evaluate("""(id) => [...document.querySelectorAll(
200
+ '[id=' + id + ']')].map(e => e.textContent.trim())""",
201
+ a.paged_item)
202
+ links = p.evaluate("""(id) => [...document.getElementById(id).children]
203
+ .map(e => e.getAttribute('href'))""", a.pagination)
204
+ check("LOOP/pagination_links", len(links) > 1 and all(links),
205
+ "%d page links: %s" % (len(links),
206
+ [l.split("?")[-1] for l in links if l][:3]))
207
+ # `p` is one of WordPress's own query vars - ?p=2 is a 301 to post 2 -
208
+ # so a paginationKey of "p" takes the visitor off the page entirely
209
+ bad_key = [l for l in links if l and ("?p=" in l or "&p=" in l)]
210
+ check("LOOP/pagination_key_is_safe", not bad_key,
211
+ "paginationKey does not collide with a WordPress query var"
212
+ if not bad_key else "uses ?p=, which WordPress reads as a post ID")
213
+ before = titles()
214
+ p.click("#%s > *:nth-child(2)" % a.pagination)
215
+ p.wait_for_timeout(2000)
216
+ after = titles()
217
+ check("LOOP/pagination_changes_rows",
218
+ bool(after) and set(after) != set(before),
219
+ "page 1 %s -> page 2 %s" % (len(before), len(after)))
121
220
 
122
221
  # ---- map
123
- src = p.evaluate("""(id) => { const f = document.querySelector('#' + id + ' iframe');
124
- return f ? f.getAttribute('src') : null; }""", a.map)
125
- m = re.search(r"marker=([-\d.]+),([-\d.]+)", src or "")
126
- times_square = bool(m) and m.group(1).startswith("40.75")
127
- check("MAP/frame", bool(src), "renders an openstreetmap iframe")
128
- check("MAP/coordinates_applied", bool(m) and not times_square,
129
- ("marker=%s,%s" % m.groups()) if m else "no marker in %r" % (src or "")[:60]
130
- + (" - this is the default, so the property name was wrong"
131
- if times_square else ""))
222
+ if not present(a.map):
223
+ skip("MAP", "not on this page")
224
+ else:
225
+ src = p.evaluate("""(id) => { const f = document.querySelector('#' + id + ' iframe');
226
+ return f ? f.getAttribute('src') : null; }""", a.map)
227
+ m = re.search(r"marker=([-\d.]+),([-\d.]+)", src or "")
228
+ default_centre = bool(m) and m.group(1).startswith("40.75")
229
+ check("MAP/frame", bool(src), "renders an openstreetmap iframe")
230
+ check("MAP/coordinates_applied", bool(m) and not default_centre,
231
+ ("marker=%s,%s" % m.groups()) if m and not default_centre
232
+ else "Times Square - the property name was wrong "
233
+ "(it is latitude/longitude, not lat/lon)")
234
+
132
235
  b.close()
133
236
 
134
237
  print("\n%d checks, %d FAIL" % (len(rows), len(fails)))