@profitlich/template-toolkit 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.project.md CHANGED
@@ -102,6 +102,22 @@ Ist ESLint eingerichtet, braucht `__DEBUG__` einen Eintrag unter `languageOption
102
102
  - Jedes Modul/Snippet hat eine eigene `.js`-Datei, die das zugehörige SCSS importiert — auch wenn sie sonst keine Logik enthält.
103
103
  - Keine globalen Styles in Modul- oder Snippet-SCSS-Dateien.
104
104
 
105
+ #### Grundregel und Breakpoint-Blöcke
106
+
107
+ **In einen `mediaquery()`-Block gehört nur, was sich pro Breakpoint unterscheidet** – in der Praxis fast nur Aufrufe mit `$layout`. Feste Werte wie `display: flex` oder `position: fixed` stehen einmal in der Grundregel oben in der Datei. Einen Breakpoint-Block nie als Kopie eines anderen anlegen: Genau so wandern feste Werte in alle drei Blöcke.
108
+
109
+ Gleich *geschriebene* `$layout`-Aufrufe wie `font($layout, 14, 20)` sind keine Wiederholung – sie ergeben pro Breakpoint andere Werte und bleiben in den Blöcken.
110
+
111
+ Das PostCSS-Plugin `@profitlich/template-toolkit/vite/postcssBreakpointDry` meldet beim Build und im Dev-Server, wo es trotzdem passiert ist: Deklarationen, die mit gleichem Selektor und Wert in Media-Queries stehen, die zusammen alle Breiten abdecken, und solche, die nur die Grundregel wiederholen. Es warnt nur. Eingebunden in `postcss.config.js`:
112
+
113
+ ```js
114
+ import postcssBreakpointDry from '@profitlich/template-toolkit/vite/postcssBreakpointDry';
115
+
116
+ export default {
117
+ plugins: [postcssBreakpointDry()],
118
+ };
119
+ ```
120
+
105
121
  #### vw-Basis
106
122
 
107
123
  Per Default skalieren vw-basierte Werte mit der Viewport-Breite inkl. Scrollbar (`100vw`). Mit `"vwBasis": "body"` als Top-Level-Feld in `src/config.json` skalieren sie stattdessen mit der scrollbar-freien Body-Breite — sinnvoll, wenn Layout-Elemente in `%` gesetzt sind und Schriften/Abstände exakt mit diesen mitskalieren sollen. Dafür im Projekt `VwBody.getInstance()` (aus `@profitlich/template-toolkit/utils/VwBody`) aufrufen — setzt die Custom Property `--vw-body` per JS. Default-Verhalten ohne Feld unverändert.
@@ -142,6 +158,28 @@ $cap: capsize-cap-height("soehne", 40); // → font-spezifischer Wert
142
158
  padding-top: size($layout, 40 - $cap + 8);
143
159
  ```
144
160
 
161
+ **Capsize und Breakpoints**: Die Trims sind em-Werte und hängen nur von der Schrift und vom Verhältnis Zeilenhöhe/Schriftgrösse ab, nicht von `$layout`. Bekommt ein Selektor in allen Breakpoints Capsize, gehört der gleichbleibende Teil in die Grundregel. Zwei Fälle:
162
+
163
+ - **Gleiches Verhältnis in allen Breakpoints** (z. B. überall 14/20): `capsize()` einmal in der Grundregel, in den Breakpoints `font()` ohne 4. Argument.
164
+
165
+ ```scss
166
+ .foo { @include capsize("soehne", 14, 20); }
167
+ @include mediaquery(tablet) using ($layout) {
168
+ .foo { @include font($layout, 14, 20); }
169
+ }
170
+ ```
171
+
172
+ - **Verschiedene Verhältnisse** (z. B. 40/70, 56/120, 111/120): `capsize-base` in der Grundregel. Es schreibt `content` und `display` der Pseudo-Elemente einmal. `font()` mit Capsize-Font gibt danach für denselben Selektor nur noch die Trims aus. Die Verschachtelung muss in Grundregel und Breakpoint gleich sein, sonst greift die Zuordnung nicht, und es wird wie bisher alles ausgegeben.
173
+
174
+ ```scss
175
+ .foo { @include capsize-base; }
176
+ @include mediaquery(tablet) using ($layout) {
177
+ .foo { @include font($layout, 56, 120, "soehne"); } // → nur Trims
178
+ }
179
+ ```
180
+
181
+ Beides nur, wenn **jeder** Breakpoint Capsize bekommt. Sonst entstehen in den übrigen Pseudo-Elemente ohne Trims, die in einem Flex- oder Grid-Container als zusätzliche Items mitlaufen.
182
+
145
183
  ### Vite Entry
146
184
 
147
185
  Einen neuen Entry in `rollupOptions.input` eintragen **nur wenn** das Script per Twig/PHP-Tag direkt eingebunden wird. Wird es von einem anderen Script importiert, braucht es keinen eigenen Entry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profitlich/template-toolkit",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "Shared SCSS layout system, JS utilities, components and build scripts for profitlich template repos",
5
5
  "type": "module",
6
6
  "sass": "scss/forward.scss",
@@ -24,7 +24,8 @@
24
24
  "./scripts/copy-files": "./scripts/copy-files.js",
25
25
  "./scripts/deploy": "./scripts/deploy.js",
26
26
  "./vite/jsonToScss": "./vite/jsonToScss.js",
27
- "./vite/capsizeSassFunctions": "./vite/capsizeSassFunctions.js"
27
+ "./vite/capsizeSassFunctions": "./vite/capsizeSassFunctions.js",
28
+ "./vite/postcssBreakpointDry": "./vite/postcssBreakpointDry.js"
28
29
  },
29
30
  "files": [
30
31
  "CLAUDE.project.md",
@@ -1,10 +1,19 @@
1
+ @use "sass:map";
1
2
  @use "sass:meta";
2
3
 
4
+ // Selektoren, deren fester Capsize-Teil per capsize-base bereits in der
5
+ // Grundregel steht. Jede SCSS-Datei ist eine eigene Sass-Kompilation, die Map
6
+ // gilt also nur innerhalb einer Datei.
7
+ $_base-selectors: ();
8
+
3
9
  // Emittiert die Capsize-Pseudo-Elemente (::before/::after mit Em-Trims)
4
10
  // für den aktuellen Selektor. Die Inhalte (welche Pseudo-Elemente, welche
5
11
  // Properties) kommen vollständig aus @capsizecss/core via der
6
12
  // Sass-Custom-Function capsize-pseudo-elements (registriert im
7
13
  // vite/capsizeSassFunctions.js des Toolkits).
14
+ //
15
+ // Steht für den Selektor capsize-base in der Grundregel, entfallen hier die
16
+ // festen Properties, und es bleiben nur die Trims.
8
17
  @mixin capsize($name, $fontSize, $lineHeight) {
9
18
  // Capsize ist opt-in: ohne registrierte Custom-Function wird übersprungen,
10
19
  // damit ein fehlender Vite-Setup nicht zu kryptischem Sass-Fehler führt.
@@ -12,12 +21,45 @@
12
21
  @warn "Capsize ist nicht eingerichtet: Custom-Function capsize-pseudo-elements fehlt. Siehe template-toolkit/CLAUDE.md, Abschnitt Capsize. Aufruf für \"#{$name}\" wird übersprungen.";
13
22
  } @else {
14
23
  $pseudo: capsize-pseudo-elements($name, $fontSize, $lineHeight);
24
+ $static: ();
25
+ @if map.has-key($_base-selectors, "#{&}") {
26
+ $static: capsize-static-properties();
27
+ }
28
+
15
29
  @each $selector-suffix, $props in $pseudo {
30
+ $skip: ();
31
+ @if map.has-key($static, $selector-suffix) {
32
+ $skip: map.get($static, $selector-suffix);
33
+ }
34
+
35
+ &#{$selector-suffix} {
36
+ @each $prop, $val in $props {
37
+ @if not map.has-key($skip, $prop) {
38
+ #{$prop}: $val;
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ // Emittiert den festen Teil der Capsize-Pseudo-Elemente (content, display)
47
+ // einmal in der Grundregel. Die Aufrufe von capsize() bzw. font() mit
48
+ // Capsize-Font in den Breakpoint-Blöcken geben danach nur noch die Trims aus.
49
+ //
50
+ // Nur verwenden, wenn der Selektor in allen Breakpoints Capsize bekommt –
51
+ // sonst entstehen dort Pseudo-Elemente ohne Trims.
52
+ @mixin capsize-base {
53
+ @if not meta.function-exists("capsize-static-properties") {
54
+ @warn "Capsize ist nicht eingerichtet: Custom-Function capsize-static-properties fehlt. Siehe template-toolkit/CLAUDE.md, Abschnitt Capsize. capsize-base wird übersprungen.";
55
+ } @else {
56
+ @each $selector-suffix, $props in capsize-static-properties() {
16
57
  &#{$selector-suffix} {
17
58
  @each $prop, $val in $props {
18
59
  #{$prop}: $val;
19
60
  }
20
61
  }
21
62
  }
63
+ $_base-selectors: map.set($_base-selectors, "#{&}", true) !global;
22
64
  }
23
65
  }
@@ -60,6 +60,33 @@ export async function createCapsizeFunctions(fontFiles) {
60
60
  return new sass.SassMap(OrderedMap(entries));
61
61
  },
62
62
 
63
+ // Liefert pro Pseudo-Element die Properties, die nicht von Schriftgrösse
64
+ // und Zeilenhöhe abhängen (heute content und display). Ermittelt aus zwei
65
+ // Aufrufen mit verschiedenem Verhältnis: Was in beiden gleich bleibt, ist
66
+ // fest. So bestimmt Capsize das Schema, nicht eine Liste im Toolkit.
67
+ 'capsize-static-properties()': () => {
68
+ const fontMetrics = Object.values(metrics)[0];
69
+ if (!fontMetrics) {
70
+ throw new Error('capsize-static-properties: Keine Font-Metriken geladen.');
71
+ }
72
+
73
+ const narrow = createStyleObject({ fontSize: 10, leading: 12, fontMetrics });
74
+ const wide = createStyleObject({ fontSize: 10, leading: 30, fontMetrics });
75
+
76
+ const entries = [];
77
+ for (const [key, val] of Object.entries(narrow)) {
78
+ if (!key.startsWith('::')) continue;
79
+ const fixed = Object.fromEntries(
80
+ Object.entries(val).filter(([prop, value]) => wide[key]?.[prop] === value)
81
+ );
82
+ entries.push([
83
+ new sass.SassString(key, { quotes: false }),
84
+ _objToSassMap(fixed),
85
+ ]);
86
+ }
87
+ return new sass.SassMap(OrderedMap(entries));
88
+ },
89
+
63
90
  'capsize-cap-height($name, $fontSize)': (args) => {
64
91
  const name = args[0].assertString('name').text;
65
92
  const fontSize = args[1].assertNumber('fontSize').value;
@@ -0,0 +1,134 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * PostCSS-Plugin, das Wiederholungen über Breakpoint-Blöcke hinweg meldet.
5
+ *
6
+ * Läuft auf dem kompilierten CSS jeder SCSS-Datei. Verschachtelung ist dort
7
+ * aufgelöst und `$layout`-Werte sind ausgerechnet. Gewarnt wird in zwei Fällen:
8
+ *
9
+ * - Eine Deklaration steht mit gleichem Selektor und gleichem Wert in
10
+ * Media-Queries, die zusammen alle Breiten abdecken (typisch: smartphone,
11
+ * tablet und desktop). Sie gehört in die Grundregel.
12
+ * - Eine Deklaration in einer Media-Query wiederholt, was die Grundregel für
13
+ * denselben Selektor bereits setzt.
14
+ *
15
+ * Das Plugin warnt nur und bricht den Build nie ab. Dateien aus `node_modules`
16
+ * werden übersprungen.
17
+ *
18
+ * @returns {import('postcss').Plugin}
19
+ */
20
+ export default function postcssBreakpointDry() {
21
+ return {
22
+ postcssPlugin: 'postcss-breakpoint-dry',
23
+ OnceExit(root, { result }) {
24
+ const file = root.source?.input.file ?? '';
25
+ if (/[\\/]node_modules[\\/]/.test(file)) return;
26
+ // Vite nennt bei PostCSS-Warnungen die Datei nicht, darum steht sie in der Meldung
27
+ const location = file ? `${path.relative(process.cwd(), file)}: ` : '';
28
+
29
+ // Selektor|Eigenschaft → Wert, jeweils der letzte Stand der Grundregeln
30
+ const base = new Map();
31
+ // Selektor|Eigenschaft → [{ value, interval, decl, baseValue }]
32
+ const media = new Map();
33
+
34
+ root.each((node) => {
35
+ if (node.type === 'rule') {
36
+ eachDecl(node, (key, value) => base.set(key, value));
37
+ return;
38
+ }
39
+ if (node.type !== 'atrule' || node.name !== 'media') return;
40
+
41
+ // Unbekannte Bedingungen wie (hover: hover) lassen sich nicht auf Breiten abbilden
42
+ const interval = parseInterval(node.params);
43
+ if (!interval) return;
44
+
45
+ node.each((rule) => {
46
+ if (rule.type !== 'rule') return;
47
+ eachDecl(rule, (key, value, decl) => {
48
+ if (!media.has(key)) media.set(key, []);
49
+ media.get(key).push({ value, interval, decl, baseValue: base.get(key) });
50
+ });
51
+ });
52
+ });
53
+
54
+ for (const [key, entries] of media) {
55
+ // Setzt eine andere Media-Query denselben Selektor anders, hängt das
56
+ // Ergebnis an der Reihenfolge – kein eindeutiger Fall
57
+ const values = new Set(entries.map((entry) => entry.value));
58
+ if (values.size > 1) continue;
59
+
60
+ const [selector, prop] = key.split('|');
61
+ const { value, decl } = entries[0];
62
+ // Pseudo-Elemente stammen meist aus font() mit Capsize-Font, behoben wird dort
63
+ const hint = /::?(before|after)\b/.test(selector) ? ' (bei Capsize: capsize() bzw. capsize-base in die Grundregel)' : '';
64
+
65
+ if (entries.every((entry) => entry.baseValue === value)) {
66
+ decl.warn(result, `${location}${selector} { ${prop}: ${value} } wiederholt die Grundregel und kann aus den Media-Queries entfallen${hint}`);
67
+ } else if (coversAllWidths(entries.map((entry) => entry.interval))) {
68
+ decl.warn(result, `${location}${selector} { ${prop}: ${value} } steht in Media-Queries, die zusammen alle Breiten abdecken, und gehört in die Grundregel${hint}`);
69
+ }
70
+ }
71
+ },
72
+ };
73
+ }
74
+
75
+ postcssBreakpointDry.postcss = true;
76
+
77
+ /** Ruft `callback` für jede direkte Deklaration einer Regel auf, mit normalisiertem Schlüssel und Wert. */
78
+ function eachDecl(rule, callback) {
79
+ const selector = rule.selectors.map((part) => part.replace(/\s+/g, ' ').trim()).join(', ');
80
+ rule.each((decl) => {
81
+ if (decl.type !== 'decl') return;
82
+ const value = decl.value.replace(/\s+/g, ' ').trim() + (decl.important ? ' !important' : '');
83
+ callback(`${selector}|${decl.prop}`, value, decl);
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Liest eine Media-Query als Breitenbereich in ganzen px, in der Form
89
+ * `(min-width: 740px)` oder `(width >= 740px)`. Liefert `null` für alles andere –
90
+ * etwa Listen, Medientypen oder weitere Merkmale wie `(hover: hover)`.
91
+ */
92
+ function parseInterval(params) {
93
+ if (params.includes(',')) return null;
94
+
95
+ const interval = { min: 0, max: Infinity };
96
+ for (const condition of params.split(/\s+and\s+/i)) {
97
+ const bound = parseBound(condition.trim());
98
+ if (!bound) return null;
99
+ if (bound.min !== undefined) interval.min = Math.max(interval.min, bound.min);
100
+ if (bound.max !== undefined) interval.max = Math.min(interval.max, bound.max);
101
+ }
102
+ return interval;
103
+ }
104
+
105
+ function parseBound(condition) {
106
+ const legacy = condition.match(/^\(\s*(min|max)-width\s*:\s*(\d+)px\s*\)$/i);
107
+ if (legacy) {
108
+ const px = Number(legacy[2]);
109
+ return legacy[1].toLowerCase() === 'min' ? { min: px } : { max: px };
110
+ }
111
+
112
+ const range = condition.match(/^\(\s*width\s*(<=|>=|<|>)\s*(\d+)px\s*\)$/i);
113
+ if (range) {
114
+ const px = Number(range[2]);
115
+ switch (range[1]) {
116
+ case '>=': return { min: px };
117
+ case '>': return { min: px + 1 };
118
+ case '<=': return { max: px };
119
+ case '<': return { max: px - 1 };
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /** Prüft, ob die Bereiche lückenlos von 0 bis unendlich reichen. */
126
+ function coversAllWidths(intervals) {
127
+ let reached = 0;
128
+ for (const { min, max } of [...intervals].sort((a, b) => a.min - b.min)) {
129
+ // max-width: 739px und min-width: 740px schliessen lückenlos aneinander an
130
+ if (min > reached) return false;
131
+ reached = Math.max(reached, max + 1);
132
+ }
133
+ return reached === Infinity;
134
+ }