@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 +38 -0
- package/package.json +3 -2
- package/scss/core/capsize.scss +42 -0
- package/vite/capsizeSassFunctions.js +27 -0
- package/vite/postcssBreakpointDry.js +134 -0
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.
|
|
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",
|
package/scss/core/capsize.scss
CHANGED
|
@@ -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
|
+
}
|