@fluid-app/fluid-cli-theme-dev 0.1.19 → 0.1.21
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/.turbo/turbo-build.log +5 -5
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/index.mjs +6 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/skills/themes-review/SKILL.md +707 -0
- package/skills/themes-review/references/blocks-vs-sections.md +131 -0
- package/skills/themes-review/references/css-js-hygiene.md +153 -0
- package/skills/themes-review/references/dead-code.md +161 -0
- package/skills/themes-review/references/dynamism.md +88 -0
- package/skills/themes-review/references/editor-attributes.md +160 -0
- package/skills/themes-review/references/examples.md +111 -0
- package/skills/themes-review/references/fairshare-attributes.md +185 -0
- package/skills/themes-review/references/global-settings.md +221 -0
- package/skills/themes-review/references/liquid-correctness.md +176 -0
- package/skills/themes-review/references/navigation.md +88 -0
- package/skills/themes-review/references/performance.md +85 -0
- package/skills/themes-review/references/security-accessibility.md +70 -0
- package/skills/themes-review/references/setting-types.md +118 -0
- package/src/commands/lint.ts +18 -2
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Global settings — typography, color, spacing, dark mode
|
|
2
|
+
|
|
3
|
+
> Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- The pattern
|
|
8
|
+
- What `config/settings_schema.json` looks like
|
|
9
|
+
- What `layouts/theme.liquid` does with them
|
|
10
|
+
- What belongs at the global level
|
|
11
|
+
- What does NOT belong at the global level
|
|
12
|
+
- Findings to surface
|
|
13
|
+
- Dark-mode patterns — two acceptable shapes
|
|
14
|
+
- Quick audit — globals health
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
The most-overlooked layer of a theme is **`config/settings_schema.json` + `layouts/theme.liquid`**. Globals are how a theme stays coherent: change one font in one place and every section follows. Reviewers should check both ends of this pipeline.
|
|
18
|
+
|
|
19
|
+
### The pattern
|
|
20
|
+
|
|
21
|
+
1. **`config/settings_schema.json`** declares theme-wide settings, grouped by `name` (typography, spacing, color_schema, shadows, border_radius, cards, etc.).
|
|
22
|
+
2. **`layouts/theme.liquid`** reads `{{ settings.* }}` and emits **CSS custom properties on `:root`** inside a `<style>` block.
|
|
23
|
+
3. **Sections and components** consume those custom properties via `var(--font-body)`, `var(--color-primary)`, etc. — never re-reading the underlying `settings.*` value.
|
|
24
|
+
|
|
25
|
+
### What `config/settings_schema.json` looks like
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
[
|
|
29
|
+
{
|
|
30
|
+
"name": "theme_info",
|
|
31
|
+
"theme_name": "My Theme",
|
|
32
|
+
"theme_version": "1.0.0",
|
|
33
|
+
"theme_author": "..."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "typography",
|
|
37
|
+
"settings": [
|
|
38
|
+
{ "type": "header", "content": "Body" },
|
|
39
|
+
{
|
|
40
|
+
"type": "font_picker",
|
|
41
|
+
"id": "font_family_body",
|
|
42
|
+
"label": "Font family",
|
|
43
|
+
"default": "Inter"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"type": "range",
|
|
47
|
+
"id": "font_weight_body",
|
|
48
|
+
"label": "Weight",
|
|
49
|
+
"min": 100,
|
|
50
|
+
"max": 900,
|
|
51
|
+
"step": 100,
|
|
52
|
+
"default": 400
|
|
53
|
+
},
|
|
54
|
+
{ "type": "header", "content": "Headings" },
|
|
55
|
+
{
|
|
56
|
+
"type": "font_picker",
|
|
57
|
+
"id": "font_family_heading",
|
|
58
|
+
"label": "Font family",
|
|
59
|
+
"default": "Inter"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"type": "range",
|
|
63
|
+
"id": "font_size_h1",
|
|
64
|
+
"label": "H1 size",
|
|
65
|
+
"min": 24,
|
|
66
|
+
"max": 96,
|
|
67
|
+
"step": 1,
|
|
68
|
+
"default": 48,
|
|
69
|
+
"unit": "px"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "color_schema",
|
|
75
|
+
"settings": [
|
|
76
|
+
{
|
|
77
|
+
"type": "color",
|
|
78
|
+
"id": "color_primary",
|
|
79
|
+
"label": "Primary",
|
|
80
|
+
"default": "#0a0a0a"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "color",
|
|
84
|
+
"id": "color_secondary",
|
|
85
|
+
"label": "Secondary",
|
|
86
|
+
"default": "#ffffff"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"type": "color",
|
|
90
|
+
"id": "color_text",
|
|
91
|
+
"label": "Body text",
|
|
92
|
+
"default": "#111111"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "appearance",
|
|
98
|
+
"settings": [
|
|
99
|
+
{
|
|
100
|
+
"type": "checkbox",
|
|
101
|
+
"id": "enable_dark_mode",
|
|
102
|
+
"label": "Enable dark mode toggle",
|
|
103
|
+
"default": false
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Note: the **outer array is grouped**, not flat. Each object with a `name:` becomes a settings group in the editor UI. The `theme_info` group is metadata and contains no `settings:` array.
|
|
111
|
+
|
|
112
|
+
### What `layouts/theme.liquid` does with them
|
|
113
|
+
|
|
114
|
+
```liquid
|
|
115
|
+
<head>
|
|
116
|
+
...
|
|
117
|
+
{{ settings.font_family_body | font_face: font_display: 'swap' }}
|
|
118
|
+
{{ settings.font_family_heading | font_face: font_display: 'swap' }}
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
:root {
|
|
122
|
+
/* Typography */
|
|
123
|
+
--font-body: {{ settings.font_family_body | font_family | default: "Inter, system-ui, sans-serif" }};
|
|
124
|
+
--font-heading: {{ settings.font_family_heading | font_family | default: "Inter, system-ui, sans-serif" }};
|
|
125
|
+
--font-weight-body: {{ settings.font_weight_body | default: 400 }};
|
|
126
|
+
--font-size-h1: {{ settings.font_size_h1 | default: 48 | append: 'px' }};
|
|
127
|
+
|
|
128
|
+
/* Color */
|
|
129
|
+
--color-primary: {{ settings.color_primary | default: '#0a0a0a' }};
|
|
130
|
+
--color-secondary: {{ settings.color_secondary | default: '#ffffff' }};
|
|
131
|
+
--color-text: {{ settings.color_text | default: '#111111' }};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
{%- if settings.enable_dark_mode -%}
|
|
135
|
+
@media (prefers-color-scheme: dark) {
|
|
136
|
+
:root {
|
|
137
|
+
--color-primary: {{ settings.color_primary_dark | default: '#ffffff' }};
|
|
138
|
+
--color-secondary: {{ settings.color_secondary_dark | default: '#0a0a0a' }};
|
|
139
|
+
--color-text: {{ settings.color_text_dark | default: '#fafafa' }};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
{%- endif -%}
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
And sections then look like:
|
|
148
|
+
|
|
149
|
+
```css
|
|
150
|
+
.featured-products h2 {
|
|
151
|
+
font-family: var(--font-heading);
|
|
152
|
+
font-size: var(--font-size-h1);
|
|
153
|
+
color: var(--color-text);
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### What belongs at the global level
|
|
158
|
+
|
|
159
|
+
Anything that should stay consistent across the whole theme:
|
|
160
|
+
|
|
161
|
+
| Group | Settings typically here |
|
|
162
|
+
| --------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
163
|
+
| `typography` | Body + heading font families, font weights, font size scale (`xs`–`9xl`), heading sizes (h1–h6), line height |
|
|
164
|
+
| `color_schema` | Brand palette (primary, secondary, accent, text, surface, border), plus dark-mode variants |
|
|
165
|
+
| `spacing` | Spacing scale, section padding scale |
|
|
166
|
+
| `layout` | Max container width, gutter, grid breakpoints |
|
|
167
|
+
| `border_radius` | Radius scale (sm, md, lg, full) |
|
|
168
|
+
| `shadows` | Shadow scale (sm, md, lg) |
|
|
169
|
+
| `cards` | Card-wide defaults (radius, shadow, padding) that per-card-type groups inherit |
|
|
170
|
+
| `appearance` | Dark mode toggle, motion-reduce, animation preferences |
|
|
171
|
+
| `theme_info` | Theme name, version, author, docs URL — **metadata only, no `settings:`** |
|
|
172
|
+
|
|
173
|
+
### What does NOT belong at the global level
|
|
174
|
+
|
|
175
|
+
- Per-section labels and copy (those are section-level)
|
|
176
|
+
- Per-page hero images, banners, CTAs (section/block-level)
|
|
177
|
+
- One-off colors used by exactly one section (section-level)
|
|
178
|
+
- Anything that varies per page or per resource
|
|
179
|
+
|
|
180
|
+
The test: _would changing this break the visual coherence of the rest of the theme?_ If yes, it's a global. If no, it's a section setting.
|
|
181
|
+
|
|
182
|
+
### Findings to surface
|
|
183
|
+
|
|
184
|
+
| You see | Severity | Fix |
|
|
185
|
+
| ------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
186
|
+
| Section adds its own `font_family` / `font_size` / `color_*` setting when the global already exists | `should` | Use `var(--font-body)`, `var(--color-primary)`, etc. Per-section overrides should be the _exception_, configured via section setting `color_override` that defaults to `unset`. |
|
|
187
|
+
| Theme has 0 global typography settings but every section hardcodes fonts in inline styles | `blocker` | Add a `typography` group to `config/settings_schema.json` and a `:root` `<style>` block in `layouts/theme.liquid`. |
|
|
188
|
+
| Theme has 0 global color settings but uses hardcoded hex everywhere | `blocker` | Add a `color_schema` group. |
|
|
189
|
+
| `layouts/theme.liquid` reads `settings.*` but emits no `<style>` / CSS variables | `should` | Wire the settings into `--var` declarations on `:root`. Without this layer, sections have no way to consume globals. |
|
|
190
|
+
| `config/settings_schema.json` is a **flat array of settings** (no `name:` groups) | `should` | The engine accepts it, but the editor UI flattens to one giant panel. Group by `name:` (typography, color, spacing, …) for usability. |
|
|
191
|
+
| `theme_info` group missing | `nit` | Adds version + author metadata visible to admins. |
|
|
192
|
+
| Two `font_family_body` and `font_family_heading` settings exist but every section uses a hardcoded `font-family: 'Inter'` | `blocker` | Rewire sections to `var(--font-heading)` / `var(--font-body)`. Globals that nothing consumes are dead weight. |
|
|
193
|
+
| Dark-mode toggle exists in schema but no `@media (prefers-color-scheme: dark)` / `[data-theme="dark"]` block in `theme.liquid` | `blocker` | Setting is a lie — wire it. |
|
|
194
|
+
|
|
195
|
+
### Dark-mode patterns — two acceptable shapes
|
|
196
|
+
|
|
197
|
+
1. **System-driven** (`@media (prefers-color-scheme: dark)`): respects OS setting, no user toggle. Lighter touch, no JS.
|
|
198
|
+
2. **Toggle-driven** (`[data-theme="dark"]` on `<html>`): explicit toggle, persisted in `localStorage`, falls back to system. Heavier but lets users override.
|
|
199
|
+
|
|
200
|
+
Reviewer's rule: if the theme has a _toggle button_ anywhere, it MUST use the toggle-driven pattern. If it has none and only the schema checkbox, system-driven is fine.
|
|
201
|
+
|
|
202
|
+
### Quick audit — globals health
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# 1. Are global groups defined?
|
|
206
|
+
jq -r '.[].name' config/settings_schema.json
|
|
207
|
+
|
|
208
|
+
# 2. Does the layout consume them?
|
|
209
|
+
grep -E 'settings\.' layouts/theme.liquid | head
|
|
210
|
+
|
|
211
|
+
# 3. Does the layout emit CSS variables?
|
|
212
|
+
grep -E '^\s*--[a-z-]+:' layouts/theme.liquid | head
|
|
213
|
+
|
|
214
|
+
# 4. Do sections actually USE the variables (vs. hardcoded)?
|
|
215
|
+
grep -rE 'font-family:\s*[A-Za-z]' --include='*.liquid' --include='*.css' . 2>/dev/null # hardcoded fonts
|
|
216
|
+
grep -rE 'color:\s*#[0-9a-fA-F]' --include='*.liquid' --include='*.css' . 2>/dev/null # hardcoded colors
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
If steps 1–3 are populated and step 4 finds lots of hardcoded fonts/colors, the globals exist but nothing uses them — file a `should` to rewire.
|
|
220
|
+
|
|
221
|
+
---
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Liquid correctness — the checks
|
|
2
|
+
|
|
3
|
+
> Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- 0. Unavailable / out-of-scope variables
|
|
8
|
+
- 1. Nil-safety and defaults
|
|
9
|
+
- 2. Whitespace control
|
|
10
|
+
- 3. Unclosed tags / mismatched conditionals
|
|
11
|
+
- 4. Filter ordering
|
|
12
|
+
- 5. `forloop.first` / `forloop.last` instead of index branching
|
|
13
|
+
- 6. `assign` inside a loop for a constant value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Mechanical. Cite line numbers.
|
|
17
|
+
|
|
18
|
+
### 0. Unavailable / out-of-scope variables
|
|
19
|
+
|
|
20
|
+
Liquid never raises on `{{ undefined_var }}` — it renders empty. The same is true for `{{ available.but.missing.field }}`. Together this means _typos and out-of-context drops are invisible at runtime._ The reviewer catches them.
|
|
21
|
+
|
|
22
|
+
The variables a template can read fall into three buckets:
|
|
23
|
+
|
|
24
|
+
| Bucket | Available in… | Examples |
|
|
25
|
+
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| **Always available globals** | every template | `request`, `cart`, `customer`, `shop`, `settings` (global), `linklists` |
|
|
27
|
+
| **Resource context** | the matching page template, plus any section it renders | `product` (only on `product/{variant}/index.liquid` and sections rendered from it); `collection`, `category`, `post`, etc. |
|
|
28
|
+
| **Section / block scope** | inside that section only | `section`, `section.settings`, `section.blocks`, `block`, `block.settings` |
|
|
29
|
+
| **Component args** | inside a component only when explicitly passed | only the keys passed to `{% render 'name', key: value %}` |
|
|
30
|
+
|
|
31
|
+
Things that go wrong:
|
|
32
|
+
|
|
33
|
+
| You see | Severity | Why |
|
|
34
|
+
| -------------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
|
35
|
+
| `{{ product.title }}` inside a section that's rendered on the homepage (`home_page/default/index.liquid`) without an explicit `product:` arg | `blocker` | `product` is only in scope on product page templates. On `home_page` it's `nil`, silently rendering empty. |
|
|
36
|
+
| `{{ collection.products }}` inside `sections/main_navbar/index.liquid` | `blocker` | `collection` is not in scope in a navbar section. Pass the resource explicitly via a block setting (`collection` type) and read `block.settings.collection.products`. |
|
|
37
|
+
| `{{ cart.item_count }}` in a component without `cart` passed as a `render` arg | `should` | `cart` is global, so this often works, but if the component is intended to be self-contained, accept `cart:` explicitly. |
|
|
38
|
+
| `{{ section.settings.thing }}` where the schema does not declare `thing` | `blocker` | Typo or stale reference. Validator does not catch this — it only checks the schema, not the template. Grep the schema for the `id` and fix. |
|
|
39
|
+
| `{{ block.settings.thing }}` where the block's `settings:` array does not declare `thing` | `blocker` | Same as above for blocks. |
|
|
40
|
+
| `{{ block.settings.x }}` inside a section that has `blocks` declared but NOT inside a `{% for block in section.blocks %}` loop | `blocker` | `block` is only in scope inside the iteration; outside it's `nil`. |
|
|
41
|
+
| Inside a component, reading `section.settings.*` (component receives no `section` arg) | `blocker` | Components are partials. They only see what's passed via `{% render %}`. The caller must pass `section: section` (rare) or the specific values needed. |
|
|
42
|
+
| Inside a component, reading `block.settings.*` without `block:` passed as an arg | `blocker` | Same as above — components are not block-aware unless told. |
|
|
43
|
+
| `{{ foo }}` where `foo` was `{% assign foo = ... %}`-ed inside an `{% if %}` branch but is read after the `{% endif %}` with no fallback | `should` | Liquid `assign` has file-level scope, but readers downstream may see `nil` if the branch didn't run. Either move the assign above the `if`, or add ` | default: ...` at the read site. |
|
|
44
|
+
| `{{ for_loop_var }}` after a `{% endfor %}` (using the loop variable outside the loop) | `blocker` | Loop variables don't escape `{% endfor %}`. The reference renders empty. |
|
|
45
|
+
| `{{ forloop.first }}` outside a `{% for %}` | `blocker` | Only valid inside a loop. |
|
|
46
|
+
|
|
47
|
+
**Audit each file before approving:**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Find references to settings keys that aren't declared in the same file's schema
|
|
51
|
+
awk '
|
|
52
|
+
/{% schema %}/,/{% endschema %}/ { schema = schema "\n" $0; next }
|
|
53
|
+
/(section|block)\.settings\.[a-zA-Z_][a-zA-Z0-9_]*/ {
|
|
54
|
+
for (i = 1; i <= NF; i++) {
|
|
55
|
+
if ($i ~ /(section|block)\.settings\.[a-zA-Z_][a-zA-Z0-9_]*/) {
|
|
56
|
+
match($i, /(section|block)\.settings\.[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
57
|
+
ref = substr($i, RSTART, RLENGTH)
|
|
58
|
+
gsub(/^(section|block)\.settings\./, "", ref)
|
|
59
|
+
if (schema !~ "\"id\":[ ]*\"" ref "\"") print FILENAME ": unknown settings.'"'"'" ref "'"'"'"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
' sections/*/index.liquid
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
(Heuristic — multi-line settings refs and computed keys will produce false positives. Use it to surface candidates, then verify.)
|
|
67
|
+
|
|
68
|
+
**When in doubt, ask one question:** _"What scope is this template in, and was the variable passed into that scope?"_ If the answer is "nothing passed it", it's not available — replace with a settings read or a `render` arg.
|
|
69
|
+
|
|
70
|
+
### 1. Nil-safety and defaults
|
|
71
|
+
|
|
72
|
+
Liquid is forgiving. `nil.foo` does not raise — it renders empty. That's worse than an error, because the bug ships silently.
|
|
73
|
+
|
|
74
|
+
**`should`** when a `section.settings.*` or `block.settings.*` value is used without `default:` in schema AND without `| default:` in the template:
|
|
75
|
+
|
|
76
|
+
```liquid
|
|
77
|
+
{%- comment -%} Bad: empty <h2> if heading is blank {%- endcomment -%}
|
|
78
|
+
<h2>{{ section.settings.heading }}</h2>
|
|
79
|
+
|
|
80
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
81
|
+
<h2>{{ section.settings.heading | default: 'Featured products' }}</h2>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**`blocker`** when a method-chain on a resource picker is unguarded and the page can render with a blank picker:
|
|
85
|
+
|
|
86
|
+
```liquid
|
|
87
|
+
{%- comment -%} Bad: `.first_variant.price` chain blows up if product is blank {%- endcomment -%}
|
|
88
|
+
<span>{{ section.settings.product.first_variant.price | money }}</span>
|
|
89
|
+
|
|
90
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
91
|
+
{%- assign p = section.settings.product -%}
|
|
92
|
+
{%- if p != blank and p.first_variant != blank -%}
|
|
93
|
+
<span>{{ p.first_variant.price | money }}</span>
|
|
94
|
+
{%- endif -%}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 2. Whitespace control
|
|
98
|
+
|
|
99
|
+
Liquid keeps every whitespace character outside tags. In tight loops, this inflates HTML by KBs. **`should`** when a `for`/`if` block in markup context lacks `-`:
|
|
100
|
+
|
|
101
|
+
```liquid
|
|
102
|
+
{%- comment -%} Bad: 6 blank lines per category {%- endcomment -%}
|
|
103
|
+
{% for category in categories %}
|
|
104
|
+
<li>
|
|
105
|
+
{% render 'category_card', category: category %}
|
|
106
|
+
</li>
|
|
107
|
+
{% endfor %}
|
|
108
|
+
|
|
109
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
110
|
+
{%- for category in categories -%}
|
|
111
|
+
<li>{%- render 'category_card', category: category -%}</li>
|
|
112
|
+
{%- endfor -%}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Rule of thumb: control tags (`{%- ... -%}`) get dashes; output tags (`{{ ... }}`) usually don't, except when adjacent to control tags.
|
|
116
|
+
|
|
117
|
+
### 3. Unclosed tags / mismatched conditionals
|
|
118
|
+
|
|
119
|
+
**`blocker`.** Liquid's parser is permissive — an unclosed `{% if %}` will consume the rest of the template silently. Quick count check:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
grep -cE '{%-?\s*(if|for|case|capture|comment|unless)\b' file.liquid
|
|
123
|
+
grep -cE '{%-?\s*end(if|for|case|capture|comment|unless)\b' file.liquid
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The two counts must match.
|
|
127
|
+
|
|
128
|
+
### 4. Filter ordering
|
|
129
|
+
|
|
130
|
+
Filters apply left-to-right. **`blocker`** when `escape` is applied before a sanitizer (re-escapes already-escaped entities) or after `truncate` (cuts in the middle of an entity, producing `&am`).
|
|
131
|
+
|
|
132
|
+
```liquid
|
|
133
|
+
{%- comment -%} Bad: truncate cuts inside & {%- endcomment -%}
|
|
134
|
+
{{ user_content | escape | truncate: 50 }}
|
|
135
|
+
|
|
136
|
+
{%- comment -%} Good: truncate first, escape last {%- endcomment -%}
|
|
137
|
+
{{ user_content | truncate: 50 | escape }}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 5. `forloop.first` / `forloop.last` instead of index branching
|
|
141
|
+
|
|
142
|
+
**`nit`** — but worth pointing out for performance + readability:
|
|
143
|
+
|
|
144
|
+
```liquid
|
|
145
|
+
{%- comment -%} Bad {%- endcomment -%}
|
|
146
|
+
{%- for item in items -%}
|
|
147
|
+
{% if forloop.index == 1 %}<div class="first">{% endif %}
|
|
148
|
+
...
|
|
149
|
+
{%- endfor -%}
|
|
150
|
+
|
|
151
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
152
|
+
{%- for item in items -%}
|
|
153
|
+
{% if forloop.first %}<div class="first">{% endif %}
|
|
154
|
+
...
|
|
155
|
+
{%- endfor -%}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 6. `assign` inside a loop for a constant value
|
|
159
|
+
|
|
160
|
+
**`should`.** Hoist invariants out:
|
|
161
|
+
|
|
162
|
+
```liquid
|
|
163
|
+
{%- comment -%} Bad: assigned every iteration {%- endcomment -%}
|
|
164
|
+
{%- for product in products -%}
|
|
165
|
+
{%- assign card_class = 'card card--' | append: section.settings.style -%}
|
|
166
|
+
<div class="{{ card_class }}">...</div>
|
|
167
|
+
{%- endfor -%}
|
|
168
|
+
|
|
169
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
170
|
+
{%- assign card_class = 'card card--' | append: section.settings.style -%}
|
|
171
|
+
{%- for product in products -%}
|
|
172
|
+
<div class="{{ card_class }}">...</div>
|
|
173
|
+
{%- endfor -%}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Navigation — link_list menus
|
|
2
|
+
|
|
3
|
+
> Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- Bad — hardcoded nav
|
|
8
|
+
- Good — one `link_list` setting
|
|
9
|
+
- Findings to surface
|
|
10
|
+
- When to keep nav items singular
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
Any series of `<a>` tags that represents a navigation (header nav, footer columns, mobile drawer, breadcrumbs, social links) belongs in a `link_list` setting — **the engine's menu selector**. Hardcoding nav items locks the company into a code change for every new link.
|
|
14
|
+
|
|
15
|
+
`link_list` is one of the canonical setting types (a single-resource picker). It points at a _menu_ the company configures in admin; the theme reads it as an iterable of menu items.
|
|
16
|
+
|
|
17
|
+
### Bad — hardcoded nav
|
|
18
|
+
|
|
19
|
+
```liquid
|
|
20
|
+
<nav class="main-nav">
|
|
21
|
+
<a href="/shop">Shop</a>
|
|
22
|
+
<a href="/about">About</a>
|
|
23
|
+
<a href="/blog">Blog</a>
|
|
24
|
+
<a href="/contact">Contact</a>
|
|
25
|
+
</nav>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or this — better, but still wrong: company-level text/URL settings paired into "nav items":
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"settings": [
|
|
33
|
+
{ "type": "text", "id": "nav_label_1", "label": "Item 1 label" },
|
|
34
|
+
{ "type": "url", "id": "nav_url_1", "label": "Item 1 link" },
|
|
35
|
+
{ "type": "text", "id": "nav_label_2", "label": "Item 2 label" },
|
|
36
|
+
{ "type": "url", "id": "nav_url_2", "label": "Item 2 link" }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Good — one `link_list` setting
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"settings": [
|
|
46
|
+
{
|
|
47
|
+
"type": "link_list",
|
|
48
|
+
"id": "menu",
|
|
49
|
+
"label": "Menu",
|
|
50
|
+
"default": "main-menu"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```liquid
|
|
57
|
+
{%- assign menu = section.settings.menu -%}
|
|
58
|
+
<nav class="main-nav" aria-label="Main navigation">
|
|
59
|
+
{%- for link in menu.links -%}
|
|
60
|
+
<a href="{{ link.url }}"
|
|
61
|
+
class="main-nav__link {% if link.active %}is-active{% endif %}">
|
|
62
|
+
{{ link.title | escape }}
|
|
63
|
+
</a>
|
|
64
|
+
{%- endfor -%}
|
|
65
|
+
</nav>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The company now picks an existing menu in admin (the editor surfaces all menus the store has configured), and adds/reorders/renames items without ever touching code. Multi-level dropdowns work via `link.links` (children).
|
|
69
|
+
|
|
70
|
+
### Findings to surface
|
|
71
|
+
|
|
72
|
+
| You see | Severity | Fix |
|
|
73
|
+
| ----------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
74
|
+
| 2+ hardcoded `<a href="/...">` tags that visually form a navigation (header, footer columns, mobile drawer, social row) | `should` | Replace with a `link_list` setting and a `{% for link in menu.links %}` loop. |
|
|
75
|
+
| Multiple parallel `text` + `url` settings used to fake a menu (`nav_label_1` / `nav_url_1`, `nav_label_2` / `nav_url_2`, ...) | `should` | Collapse to one `link_list`. Each menu item gains drag-reorder, depth, and active-state tracking. |
|
|
76
|
+
| Section that lists footer columns by hardcoding the same column twice | `should` | One block per column with a `link_list` inside the block. Users add/remove/rename columns. |
|
|
77
|
+
| Iterating `linklists` (the global) without exposing a `link_list` picker | `nit` | Acceptable for "site-wide menu" cases, but giving the user a `link_list` setting is more flexible — the section can be reused for different menus. |
|
|
78
|
+
| Breadcrumbs, social-link rows, related-links rails — anything that's a list of `{ label, url }` pairs | `should` | All belong in a `link_list`. Don't reinvent. |
|
|
79
|
+
|
|
80
|
+
### When to keep nav items singular
|
|
81
|
+
|
|
82
|
+
The `link_list` heuristic does not apply to:
|
|
83
|
+
|
|
84
|
+
- **Single CTAs** in marketing sections — these are real "hero button" / "secondary button" roles. A standalone `text` + `url` (or one `link_list` with `limit: 1` if you want consistency) is fine.
|
|
85
|
+
- **Brand logo links** — the logo's `href` is almost always "go home"; a fixed `/` is acceptable, or expose a single `url` setting.
|
|
86
|
+
- **Legal/footer links that are _required by policy_** (Terms, Privacy) and not the company's call to omit — but even these are usually better as a `link_list` so they can be reordered or extended.
|
|
87
|
+
|
|
88
|
+
---
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Performance
|
|
2
|
+
|
|
3
|
+
> Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- 1. `asset_url` inside a loop
|
|
8
|
+
- 2. Unbounded `for` over a large collection
|
|
9
|
+
- 3. `sort` on large arrays
|
|
10
|
+
- 4. Repeated `section.settings.X` access
|
|
11
|
+
- 5. JS without `defer`
|
|
12
|
+
- 6. CSS scoping
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### 1. `asset_url` inside a loop
|
|
16
|
+
|
|
17
|
+
**`blocker`** if it generates a unique URL per iteration, **`should`** if the URL is the same every time. Either way, hoist:
|
|
18
|
+
|
|
19
|
+
```liquid
|
|
20
|
+
{%- comment -%} Bad: same URL resolved N times {%- endcomment -%}
|
|
21
|
+
{%- for product in products -%}
|
|
22
|
+
<img src="{{ 'placeholder.png' | asset_url }}" />
|
|
23
|
+
{%- endfor -%}
|
|
24
|
+
|
|
25
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
26
|
+
{%- assign placeholder_src = 'placeholder.png' | asset_url -%}
|
|
27
|
+
{%- for product in products -%}
|
|
28
|
+
<img src="{{ placeholder_src }}" />
|
|
29
|
+
{%- endfor -%}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Unbounded `for` over a large collection
|
|
33
|
+
|
|
34
|
+
**`should`** when iterating `collection.products` or any collection that can grow unbounded without `limit:`:
|
|
35
|
+
|
|
36
|
+
```liquid
|
|
37
|
+
{%- comment -%} Bad {%- endcomment -%}
|
|
38
|
+
{%- for product in collection.products -%} ... {%- endfor -%}
|
|
39
|
+
|
|
40
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
41
|
+
{%- for product in collection.products limit: 12 -%} ... {%- endfor -%}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For paginated views, use `{% paginate %}`.
|
|
45
|
+
|
|
46
|
+
### 3. `sort` on large arrays
|
|
47
|
+
|
|
48
|
+
**`should`.** Liquid `sort` is in-memory and O(n log n) every render. Pre-sort at the data layer when possible, or cache via `{% capture %}` after the first sort.
|
|
49
|
+
|
|
50
|
+
### 4. Repeated `section.settings.X` access
|
|
51
|
+
|
|
52
|
+
**`nit`/`should`.** If the same setting is read 5+ times in markup, alias it once at the top:
|
|
53
|
+
|
|
54
|
+
```liquid
|
|
55
|
+
{%- assign show_title = section.settings.show_title -%}
|
|
56
|
+
{%- assign title_tag = section.settings.title_tag | default: 'h2' -%}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Improves perf and makes the section's "inputs" readable in one block.
|
|
60
|
+
|
|
61
|
+
### 5. JS without `defer`
|
|
62
|
+
|
|
63
|
+
**`should`.** Render-blocking script tags hurt LCP:
|
|
64
|
+
|
|
65
|
+
```liquid
|
|
66
|
+
{%- comment -%} Bad {%- endcomment -%}
|
|
67
|
+
<script src="{{ 'cart_page.js' | asset_url }}"></script>
|
|
68
|
+
|
|
69
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
70
|
+
<script src="{{ 'cart_page.js' | asset_url }}" defer></script>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Exception: scripts that must run before DOM (rare in themes).
|
|
74
|
+
|
|
75
|
+
### 6. CSS scoping
|
|
76
|
+
|
|
77
|
+
**`should`.** Per-section CSS belongs in the section file:
|
|
78
|
+
|
|
79
|
+
```liquid
|
|
80
|
+
{{ 'cart_page.css' | asset_url | stylesheet_tag }}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Loading it in `layouts/theme.liquid` ships it on every page. The engine deduplicates stylesheets across the page, so it's safe to declare per-section.
|
|
84
|
+
|
|
85
|
+
---
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Security & accessibility
|
|
2
|
+
|
|
3
|
+
> Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- 1. Unescaped user content
|
|
8
|
+
- 2. Raw output of URLs in `href` / `src`
|
|
9
|
+
- 3. Comments with secrets
|
|
10
|
+
- 1. Missing `alt` on images
|
|
11
|
+
- 2. Heading hierarchy
|
|
12
|
+
- 3. Button vs link semantics
|
|
13
|
+
- 4. `decoding="async"` and `loading="lazy"`
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### 1. Unescaped user content
|
|
17
|
+
|
|
18
|
+
**`blocker`.** Any string that originated from a user (product title, comment, custom heading) outside of a `richtext`/`html`/`html_textarea` field must be escaped. Default to `| escape` for any string field. The only exception is the HTML-shaped types — those are HTML by design.
|
|
19
|
+
|
|
20
|
+
```liquid
|
|
21
|
+
<h2>{{ product.title | escape }}</h2>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 2. Raw output of URLs in `href` / `src`
|
|
25
|
+
|
|
26
|
+
**`should`.** Escape:
|
|
27
|
+
|
|
28
|
+
```liquid
|
|
29
|
+
<a href="{{ section.settings.link_url | escape }}">Link</a>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 3. Comments with secrets
|
|
33
|
+
|
|
34
|
+
**`should`.** `{% comment %}` blocks are not rendered, but they _are_ shipped to clients if the theme source is exposed (CDN, git). Don't paste tokens or internal URLs in comments.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Accessibility
|
|
39
|
+
|
|
40
|
+
`should` by default. Bump to `blocker` if the section is the storefront homepage or checkout flow.
|
|
41
|
+
|
|
42
|
+
### 1. Missing `alt` on images
|
|
43
|
+
|
|
44
|
+
```liquid
|
|
45
|
+
{%- comment -%} Bad {%- endcomment -%}
|
|
46
|
+
<img src="{{ product.featured_image }}" />
|
|
47
|
+
|
|
48
|
+
{%- comment -%} Good {%- endcomment -%}
|
|
49
|
+
<img src="{{ product.featured_image }}" alt="{{ product.title | escape }}" />
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Decorative images: `alt=""`.
|
|
53
|
+
|
|
54
|
+
### 2. Heading hierarchy
|
|
55
|
+
|
|
56
|
+
A section shouldn't skip from `h1` to `h4`. Make heading tag a setting and default sensibly (`h2` for section titles, `h3` for cards).
|
|
57
|
+
|
|
58
|
+
### 3. Button vs link semantics
|
|
59
|
+
|
|
60
|
+
A clickable `<div>` is `blocker`. Use `<button>` for actions, `<a href>` for navigation.
|
|
61
|
+
|
|
62
|
+
### 4. `decoding="async"` and `loading="lazy"`
|
|
63
|
+
|
|
64
|
+
```liquid
|
|
65
|
+
<img src="..." alt="..." loading="lazy" decoding="async" />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Don't set `loading="lazy"` on hero/above-the-fold images — it delays LCP.
|
|
69
|
+
|
|
70
|
+
---
|