strata-css 1.0.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/CHANGELOG.md +62 -0
- package/LICENSE +21 -0
- package/README.md +730 -0
- package/bin/strata.js +139 -0
- package/index.d.ts +70 -0
- package/package.json +68 -0
- package/src/components/modules/modal.js +123 -0
- package/src/components/modules/skeleton.js +334 -0
- package/src/components/strata.manifest.js +15 -0
- package/src/generator/generator.js +113 -0
- package/src/index.js +172 -0
- package/src/layers/base.js +571 -0
- package/src/registry/breakpoints.js +54 -0
- package/src/registry/registry.js +2898 -0
- package/src/scanner/scanner.js +106 -0
- package/strata.css +3 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strata Skeleton JS Utility
|
|
3
|
+
* Version: 1.0.0
|
|
4
|
+
*
|
|
5
|
+
* Smart detection and lifecycle management for skeleton loaders.
|
|
6
|
+
* JS handles detection and toggling only.
|
|
7
|
+
* CSS handles all visual rendering via @layer st-skeleton.
|
|
8
|
+
*
|
|
9
|
+
* Four attribute states:
|
|
10
|
+
* "true" — element shimmers (CSS applies ::before overlay)
|
|
11
|
+
* "false" — element revealed (no shimmer)
|
|
12
|
+
* "null" — JS managed parent (no overlay, children shimmer individually)
|
|
13
|
+
*
|
|
14
|
+
* Key fix: replaced elements (img, video, iframe) cannot have ::before
|
|
15
|
+
* pseudo-elements in browsers. JS marks their WRAPPER div with "true"
|
|
16
|
+
* instead so the wrapper's ::before overlay covers the replaced content.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
;(function (global) {
|
|
20
|
+
'use strict'
|
|
21
|
+
|
|
22
|
+
// ─── Element type sets ───────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// Content elements that support ::before — become individual skeleton bars
|
|
25
|
+
const CONTENT_TAGS = new Set([
|
|
26
|
+
'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
27
|
+
'SPAN', 'A', 'LABEL', 'STRONG', 'EM', 'SMALL',
|
|
28
|
+
'BUTTON', 'INPUT', 'TEXTAREA', 'SELECT',
|
|
29
|
+
'LI', 'DT', 'DD', 'BLOCKQUOTE', 'FIGCAPTION'
|
|
30
|
+
// NOTE: IMG, VIDEO, IFRAME are NOT here —
|
|
31
|
+
// replaced elements cannot have ::before.
|
|
32
|
+
// Their parent WRAPPER is marked instead.
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
// Replaced elements — ::before not supported by browsers
|
|
36
|
+
// JS marks their wrapper parent instead
|
|
37
|
+
const REPLACED_TAGS = new Set([
|
|
38
|
+
'IMG', 'VIDEO', 'IFRAME', 'PICTURE', 'CANVAS', 'SVG'
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
// Structural elements — skipped, children scanned
|
|
42
|
+
// UNLESS they contain only replaced elements, in which case
|
|
43
|
+
// the structural element itself becomes the skeleton bar
|
|
44
|
+
const STRUCTURAL_TAGS = new Set([
|
|
45
|
+
'DIV', 'SECTION', 'ARTICLE', 'MAIN', 'ASIDE',
|
|
46
|
+
'HEADER', 'FOOTER', 'NAV', 'UL', 'OL', 'DL',
|
|
47
|
+
'FIGURE', 'FORM', 'FIELDSET'
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
// Tags to ignore completely
|
|
51
|
+
const IGNORE_TAGS = new Set([
|
|
52
|
+
'SCRIPT', 'STYLE', 'NOSCRIPT', 'META', 'LINK',
|
|
53
|
+
'HEAD', 'HTML', 'BODY', 'BR', 'HR', 'WBR'
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
// ─── Registry ────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const registry = new Map()
|
|
59
|
+
|
|
60
|
+
// ─── Smart Detection ─────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if an element has direct text nodes (not just whitespace)
|
|
64
|
+
*/
|
|
65
|
+
function hasDirectText(el) {
|
|
66
|
+
return Array.from(el.childNodes).some(
|
|
67
|
+
node => node.nodeType === Node.TEXT_NODE
|
|
68
|
+
&& node.textContent.trim().length > 0
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if a structural element contains ONLY replaced elements.
|
|
74
|
+
* If so, the structural element itself becomes the skeleton bar
|
|
75
|
+
* because its ::before overlay covers the replaced content.
|
|
76
|
+
*
|
|
77
|
+
* Example: <div class="card-img-wrap"><img src="..."></div>
|
|
78
|
+
* → card-img-wrap gets "true", its ::before covers the img
|
|
79
|
+
*/
|
|
80
|
+
function hasOnlyReplacedChildren(el) {
|
|
81
|
+
const meaningful = Array.from(el.children).filter(
|
|
82
|
+
c => !IGNORE_TAGS.has(c.tagName)
|
|
83
|
+
)
|
|
84
|
+
return meaningful.length > 0
|
|
85
|
+
&& meaningful.every(c => REPLACED_TAGS.has(c.tagName))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Walk the DOM from a parent and find all leaf nodes.
|
|
90
|
+
*
|
|
91
|
+
* Rules:
|
|
92
|
+
* - Content tags (p, h3, button, a etc.) → mark as skeleton bar
|
|
93
|
+
* - Structural wrapper containing ONLY replaced elements (img, video) →
|
|
94
|
+
* mark the WRAPPER as skeleton bar (its ::before covers the media)
|
|
95
|
+
* - Structural wrapper with direct text → mark as skeleton bar
|
|
96
|
+
* - Structural wrapper with mixed children → recurse
|
|
97
|
+
* - Replaced elements (img, video) as direct children → skip
|
|
98
|
+
* (handled by their parent wrapper rule above)
|
|
99
|
+
* - data-st-skeleton="false" → skip element and all descendants
|
|
100
|
+
* - data-st-skeleton="null" → skip (another managed parent)
|
|
101
|
+
*/
|
|
102
|
+
function detectLeaves(parent) {
|
|
103
|
+
const leaves = []
|
|
104
|
+
|
|
105
|
+
function walk(el) {
|
|
106
|
+
// Skip opted-out elements entirely
|
|
107
|
+
if (el.getAttribute('data-st-skeleton') === 'false') return
|
|
108
|
+
|
|
109
|
+
// Skip other managed JS parents
|
|
110
|
+
if (el !== parent && el.getAttribute('data-st-skeleton') === 'null') return
|
|
111
|
+
|
|
112
|
+
// Skip ignored tags
|
|
113
|
+
if (IGNORE_TAGS.has(el.tagName)) return
|
|
114
|
+
|
|
115
|
+
const tag = el.tagName
|
|
116
|
+
|
|
117
|
+
// Content element — supports ::before, mark as leaf
|
|
118
|
+
if (CONTENT_TAGS.has(tag)) {
|
|
119
|
+
leaves.push(el)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Replaced element as direct child of managed parent
|
|
124
|
+
// Cannot mark it — no ::before support. Skip it.
|
|
125
|
+
// Ideally developer wraps their images in a div.
|
|
126
|
+
if (REPLACED_TAGS.has(tag)) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Structural element — decide whether to mark or recurse
|
|
131
|
+
if (STRUCTURAL_TAGS.has(tag)) {
|
|
132
|
+
|
|
133
|
+
// CASE 1: Contains only replaced elements (img, video etc.)
|
|
134
|
+
// Mark THIS structural element — its ::before covers the media
|
|
135
|
+
if (hasOnlyReplacedChildren(el)) {
|
|
136
|
+
leaves.push(el)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// CASE 2: Has direct text — mark as leaf
|
|
141
|
+
if (hasDirectText(el)) {
|
|
142
|
+
leaves.push(el)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// CASE 3: Mixed children — recurse into children
|
|
147
|
+
Array.from(el.children).forEach(child => walk(child))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Unknown tag — recurse into children
|
|
152
|
+
Array.from(el.children).forEach(child => walk(child))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Walk children — not the parent itself
|
|
156
|
+
Array.from(parent.children).forEach(child => walk(child))
|
|
157
|
+
|
|
158
|
+
return leaves
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Core ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Register a parent for JS management.
|
|
165
|
+
* Sets parent to "null" — neutral container, no parent overlay.
|
|
166
|
+
* Runs smart detection and marks leaf children with "true".
|
|
167
|
+
*/
|
|
168
|
+
function manage(parent, options = {}) {
|
|
169
|
+
if (!parent || registry.has(parent)) return
|
|
170
|
+
|
|
171
|
+
const leaves = detectLeaves(parent)
|
|
172
|
+
registry.set(parent, { leaves, options })
|
|
173
|
+
|
|
174
|
+
// Set parent to "null" — removes parent ::before overlay
|
|
175
|
+
// so only individual leaf shimmers show
|
|
176
|
+
parent.setAttribute('data-st-skeleton', 'null')
|
|
177
|
+
|
|
178
|
+
// Mark detected leaves as skeleton
|
|
179
|
+
leaves.forEach(leaf => leaf.setAttribute('data-st-skeleton', 'true'))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Show skeleton — parent to "null", leaves to "true"
|
|
184
|
+
*/
|
|
185
|
+
function show(selector) {
|
|
186
|
+
const targets = resolveTargets(selector)
|
|
187
|
+
targets.forEach(parent => {
|
|
188
|
+
const entry = registry.get(parent)
|
|
189
|
+
if (!entry) return
|
|
190
|
+
parent.setAttribute('data-st-skeleton', 'null')
|
|
191
|
+
entry.leaves.forEach(leaf => leaf.setAttribute('data-st-skeleton', 'true'))
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reveal content — parent to "false", leaves to "false"
|
|
197
|
+
* Attribute stays in DOM — only value changes.
|
|
198
|
+
*/
|
|
199
|
+
function reveal(selector, options = {}) {
|
|
200
|
+
if (typeof selector === 'object' && !isSelector(selector)) {
|
|
201
|
+
options = selector
|
|
202
|
+
selector = null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const targets = resolveTargets(selector)
|
|
206
|
+
const stagger = options.stagger || 0
|
|
207
|
+
const onReveal = options.onReveal || null
|
|
208
|
+
|
|
209
|
+
targets.forEach((parent, index) => {
|
|
210
|
+
const entry = registry.get(parent)
|
|
211
|
+
if (!entry) return
|
|
212
|
+
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
parent.setAttribute('data-st-skeleton', 'false')
|
|
215
|
+
entry.leaves.forEach(leaf => leaf.setAttribute('data-st-skeleton', 'false'))
|
|
216
|
+
|
|
217
|
+
if (onReveal && index === targets.length - 1) {
|
|
218
|
+
setTimeout(onReveal, 0)
|
|
219
|
+
}
|
|
220
|
+
}, stagger * index)
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Toggle between skeleton and revealed
|
|
226
|
+
*/
|
|
227
|
+
function toggle(selector) {
|
|
228
|
+
const targets = resolveTargets(selector)
|
|
229
|
+
targets.forEach(parent => {
|
|
230
|
+
const val = parent.getAttribute('data-st-skeleton')
|
|
231
|
+
val === 'false' ? show(parent) : reveal(parent)
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Reveal one element by index within a selector group
|
|
237
|
+
*/
|
|
238
|
+
function revealAt(selector, index) {
|
|
239
|
+
const targets = resolveTargets(selector)
|
|
240
|
+
const parent = targets[index]
|
|
241
|
+
if (parent) reveal(parent)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if element is currently in skeleton state
|
|
246
|
+
*/
|
|
247
|
+
function isSkeleton(el) {
|
|
248
|
+
const val = el.getAttribute('data-st-skeleton')
|
|
249
|
+
return val === 'true' || val === 'null'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Init ─────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Initialise the skeleton utility.
|
|
256
|
+
*
|
|
257
|
+
* No selector: auto-discovers all [data-st-skeleton="true"] top-level parents.
|
|
258
|
+
* With selector: manages all matching elements.
|
|
259
|
+
*/
|
|
260
|
+
function init(selector, options = {}) {
|
|
261
|
+
let parents
|
|
262
|
+
|
|
263
|
+
if (!selector) {
|
|
264
|
+
// Auto-discover top-level skeleton parents
|
|
265
|
+
// Filter out elements that are children of another skeleton parent
|
|
266
|
+
parents = Array.from(
|
|
267
|
+
document.querySelectorAll('[data-st-skeleton="true"]')
|
|
268
|
+
).filter(el => !el.parentElement.closest('[data-st-skeleton]'))
|
|
269
|
+
|
|
270
|
+
} else {
|
|
271
|
+
parents = resolveRaw(selector)
|
|
272
|
+
|
|
273
|
+
// Set initial attribute if missing
|
|
274
|
+
parents.forEach(parent => {
|
|
275
|
+
if (!parent.hasAttribute('data-st-skeleton')) {
|
|
276
|
+
parent.setAttribute('data-st-skeleton', 'true')
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
parents.forEach(parent => manage(parent, options))
|
|
282
|
+
return api
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function resolveTargets(selector) {
|
|
288
|
+
if (!selector) return Array.from(registry.keys())
|
|
289
|
+
if (selector instanceof Element) return registry.has(selector) ? [selector] : []
|
|
290
|
+
if (selector instanceof NodeList || Array.isArray(selector)) {
|
|
291
|
+
return Array.from(selector).filter(el => registry.has(el))
|
|
292
|
+
}
|
|
293
|
+
if (typeof selector === 'string') {
|
|
294
|
+
return Array.from(document.querySelectorAll(selector))
|
|
295
|
+
.filter(el => registry.has(el))
|
|
296
|
+
}
|
|
297
|
+
return []
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function resolveRaw(selector) {
|
|
301
|
+
if (!selector) return []
|
|
302
|
+
if (selector instanceof Element) return [selector]
|
|
303
|
+
if (selector instanceof NodeList || Array.isArray(selector)) return Array.from(selector)
|
|
304
|
+
if (typeof selector === 'string') return Array.from(document.querySelectorAll(selector))
|
|
305
|
+
return []
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isSelector(val) {
|
|
309
|
+
return typeof val === 'string'
|
|
310
|
+
|| val instanceof Element
|
|
311
|
+
|| val instanceof NodeList
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── Public API ───────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const api = {
|
|
317
|
+
init,
|
|
318
|
+
show,
|
|
319
|
+
reveal,
|
|
320
|
+
toggle,
|
|
321
|
+
revealAt,
|
|
322
|
+
isSkeleton,
|
|
323
|
+
manage: (selector, options) => {
|
|
324
|
+
resolveRaw(selector).forEach(el => manage(el, options))
|
|
325
|
+
return api
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Export ───────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
if (!global.Strata) global.Strata = {}
|
|
332
|
+
global.Strata.skeleton = api
|
|
333
|
+
|
|
334
|
+
})(window)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Strata Components — Entry Point
|
|
3
|
+
*
|
|
4
|
+
* This file is the bundle manifest.
|
|
5
|
+
* The build script concatenates all files in src/js/components/ (in filename order)
|
|
6
|
+
* and writes the result to dist/strata.components.js.
|
|
7
|
+
*
|
|
8
|
+
* To add a new component:
|
|
9
|
+
* 1. Create src/js/components/[name].js
|
|
10
|
+
* 2. Run: npm run build
|
|
11
|
+
*
|
|
12
|
+
* Current components:
|
|
13
|
+
* modal.js — Modal (data-st-toggle, data-st-dismiss, data-st-backdrop)
|
|
14
|
+
* skeleton.js — Skeleton (Strata.skeleton.init / show / reveal / toggle)
|
|
15
|
+
*/
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strata Generator — v4 — Sub-layer routing
|
|
3
|
+
*
|
|
4
|
+
* Each CSS rule is routed to its correct breakpoint sub-layer.
|
|
5
|
+
* Layer declaration order in base.js ensures correct cascade priority:
|
|
6
|
+
*
|
|
7
|
+
* st-components-xs < st-components-sm < st-components-md < ...
|
|
8
|
+
* st-utilities-xs < st-utilities-sm < st-utilities-md < ...
|
|
9
|
+
*
|
|
10
|
+
* This means HTML class order NEVER affects CSS behaviour.
|
|
11
|
+
* col-lg-4 always beats col-sm-8 at large screens regardless of
|
|
12
|
+
* which class appears first in the HTML attribute.
|
|
13
|
+
*
|
|
14
|
+
* Routing logic:
|
|
15
|
+
* No media query → xs sub-layer (mobile first default)
|
|
16
|
+
* min-width: 576px → sm sub-layer
|
|
17
|
+
* min-width: 768px → md sub-layer
|
|
18
|
+
* min-width: 992px → lg sub-layer
|
|
19
|
+
* min-width: 1200px → xl sub-layer
|
|
20
|
+
* min-width: 1400px → xxl sub-layer
|
|
21
|
+
*
|
|
22
|
+
* Media query is kept inside the layer — it still controls when
|
|
23
|
+
* the style activates. The layer controls cascade priority between
|
|
24
|
+
* breakpoints.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict'
|
|
28
|
+
|
|
29
|
+
const { lookup } = require('../registry/registry')
|
|
30
|
+
|
|
31
|
+
// ─── Breakpoint routing ───────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const BP_SUFFIX_MAP = [
|
|
34
|
+
{ px: 1400, suffix: 'xxl' },
|
|
35
|
+
{ px: 1200, suffix: 'xl' },
|
|
36
|
+
{ px: 992, suffix: 'lg' },
|
|
37
|
+
{ px: 768, suffix: 'md' },
|
|
38
|
+
{ px: 576, suffix: 'sm' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Determine which sub-layer suffix a CSS rule belongs to.
|
|
43
|
+
* Reads the min-width value from the @media query if present.
|
|
44
|
+
* Returns 'xs' for rules with no media query.
|
|
45
|
+
*/
|
|
46
|
+
function getSubLayerSuffix(css) {
|
|
47
|
+
const match = css.match(/@media\s*\(min-width:\s*(\d+(?:\.\d+)?)px\)/)
|
|
48
|
+
if (!match) return 'xs'
|
|
49
|
+
const px = parseFloat(match[1])
|
|
50
|
+
for (const { px: threshold, suffix } of BP_SUFFIX_MAP) {
|
|
51
|
+
if (px >= threshold) return suffix
|
|
52
|
+
}
|
|
53
|
+
return 'sm'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Generator ───────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function generate(classNames, config = {}) {
|
|
59
|
+
// Buckets: layer → suffix → [css strings]
|
|
60
|
+
const buckets = {
|
|
61
|
+
components: { xs:[], sm:[], md:[], lg:[], xl:[], xxl:[] },
|
|
62
|
+
utilities: { xs:[], sm:[], md:[], lg:[], xl:[], xxl:[] },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const cls of classNames) {
|
|
66
|
+
const result = lookup(cls)
|
|
67
|
+
if (!result) continue
|
|
68
|
+
|
|
69
|
+
const suffix = getSubLayerSuffix(result.css)
|
|
70
|
+
const group = result.layer === 'components' ? 'components' : 'utilities'
|
|
71
|
+
buckets[group][suffix].push(result.css)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SUFFIXES = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']
|
|
75
|
+
|
|
76
|
+
const componentParts = []
|
|
77
|
+
const utilityParts = []
|
|
78
|
+
|
|
79
|
+
for (const suffix of SUFFIXES) {
|
|
80
|
+
if (buckets.components[suffix].length > 0) {
|
|
81
|
+
const layerName = `st-components-${suffix}`
|
|
82
|
+
const body = buckets.components[suffix].map(r => indent(r)).join('\n\n')
|
|
83
|
+
componentParts.push(`@layer ${layerName} {\n${body}\n}`)
|
|
84
|
+
}
|
|
85
|
+
if (buckets.utilities[suffix].length > 0) {
|
|
86
|
+
const layerName = `st-utilities-${suffix}`
|
|
87
|
+
const body = buckets.utilities[suffix].map(r => indent(r)).join('\n\n')
|
|
88
|
+
utilityParts.push(`@layer ${layerName} {\n${body}\n}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const componentCSS = componentParts.join('\n\n')
|
|
93
|
+
const utilityCSS = utilityParts.join('\n\n')
|
|
94
|
+
|
|
95
|
+
return { componentCSS, utilityCSS }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function indent(css) {
|
|
99
|
+
return css.split('\n').map(line => ' ' + line).join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Keep generateAST for compatibility
|
|
103
|
+
function generateAST(classNames, config = {}) {
|
|
104
|
+
const postcss = require('postcss')
|
|
105
|
+
const { componentCSS, utilityCSS } = generate(classNames, config)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
componentAST: componentCSS ? postcss.parse(componentCSS) : null,
|
|
109
|
+
utilityAST: utilityCSS ? postcss.parse(utilityCSS) : null,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { generate, generateAST }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strata CSS — PostCSS Plugin — Optimised v6
|
|
3
|
+
*
|
|
4
|
+
* Warm builds bypass PostCSS entirely — no node creation, no GC pressure
|
|
5
|
+
* Cold builds run full PostCSS pipeline (including autoprefixer etc.)
|
|
6
|
+
* Warm builds return cached string directly — zero object allocation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict'
|
|
10
|
+
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
let postcss
|
|
14
|
+
|
|
15
|
+
// ─── State ────────────────────────────────────────────────────────────
|
|
16
|
+
let dirty = true
|
|
17
|
+
let cachedCSS = null // final output CSS string from last cold build
|
|
18
|
+
|
|
19
|
+
// ─── Config cache ─────────────────────────────────────────────────────
|
|
20
|
+
let cachedConfig = null
|
|
21
|
+
let cachedConfigPath = null
|
|
22
|
+
let cachedConfigMtime = 0
|
|
23
|
+
|
|
24
|
+
function loadConfig(cwd) {
|
|
25
|
+
const configPath = path.resolve(cwd, 'strata.config.js')
|
|
26
|
+
let mtime = 0
|
|
27
|
+
try { mtime = fs.statSync(configPath).mtimeMs } catch {}
|
|
28
|
+
if (cachedConfig && cachedConfigPath === configPath && cachedConfigMtime === mtime) {
|
|
29
|
+
return cachedConfig
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
delete require.cache[require.resolve(configPath)]
|
|
33
|
+
cachedConfig = require(configPath)
|
|
34
|
+
cachedConfigPath = configPath
|
|
35
|
+
cachedConfigMtime = mtime
|
|
36
|
+
} catch { cachedConfig = {} }
|
|
37
|
+
return cachedConfig
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Base CSS ─────────────────────────────────────────────────────────
|
|
41
|
+
const BASE_CSS = require('./layers/base').trim()
|
|
42
|
+
let BASE_AST = null
|
|
43
|
+
|
|
44
|
+
function getBaseAST() {
|
|
45
|
+
if (!postcss) postcss = require('postcss')
|
|
46
|
+
if (!BASE_AST) BASE_AST = postcss.parse(BASE_CSS)
|
|
47
|
+
return BASE_AST
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Input CSS cache ───────────────────────────────────────────────────
|
|
51
|
+
// strata.css rarely changes — cache it with mtime check
|
|
52
|
+
let cachedInputCSS = null
|
|
53
|
+
let cachedInputPath = null
|
|
54
|
+
let cachedInputMtime = 0
|
|
55
|
+
|
|
56
|
+
function readInputCSS(inputCSSPath) {
|
|
57
|
+
let mtime = 0
|
|
58
|
+
try { mtime = fs.statSync(inputCSSPath).mtimeMs } catch {}
|
|
59
|
+
if (cachedInputCSS && cachedInputPath === inputCSSPath && cachedInputMtime === mtime) {
|
|
60
|
+
return cachedInputCSS
|
|
61
|
+
}
|
|
62
|
+
cachedInputCSS = fs.readFileSync(inputCSSPath, 'utf8')
|
|
63
|
+
cachedInputPath = inputCSSPath
|
|
64
|
+
cachedInputMtime = mtime
|
|
65
|
+
return cachedInputCSS
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── PostCSS Plugin ───────────────────────────────────────────────────
|
|
69
|
+
// Only runs on cold builds — warm builds bypass via strata.build()
|
|
70
|
+
|
|
71
|
+
const plugin = (opts = {}) => ({
|
|
72
|
+
postcssPlugin: 'strata-css',
|
|
73
|
+
|
|
74
|
+
async Once(root) {
|
|
75
|
+
if (!postcss) postcss = require('postcss')
|
|
76
|
+
|
|
77
|
+
const cwd = opts.cwd || process.cwd()
|
|
78
|
+
const config = loadConfig(cwd)
|
|
79
|
+
|
|
80
|
+
const { scanFiles } = require('./scanner/scanner')
|
|
81
|
+
const { generate } = require('./generator/generator')
|
|
82
|
+
|
|
83
|
+
const contentGlobs = config.content || [
|
|
84
|
+
'./src/**/*.{html,jsx,tsx,vue,astro,svelte,js,ts}'
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const classNames = scanFiles(contentGlobs)
|
|
88
|
+
const { componentCSS, utilityCSS } = generate(classNames, config)
|
|
89
|
+
|
|
90
|
+
// Replace @strata directives
|
|
91
|
+
let baseInserted = false
|
|
92
|
+
root.walkAtRules('strata', rule => {
|
|
93
|
+
const d = rule.params.trim()
|
|
94
|
+
if (d === 'base' && !baseInserted) {
|
|
95
|
+
rule.replaceWith(getBaseAST().clone())
|
|
96
|
+
baseInserted = true
|
|
97
|
+
} else if (d === 'components') {
|
|
98
|
+
// componentCSS now contains multiple @layer sub-layer blocks
|
|
99
|
+
componentCSS ? rule.replaceWith(postcss.parse(componentCSS)) : rule.remove()
|
|
100
|
+
} else if (d === 'utilities') {
|
|
101
|
+
// utilityCSS now contains multiple @layer sub-layer blocks
|
|
102
|
+
utilityCSS ? rule.replaceWith(postcss.parse(utilityCSS)) : rule.remove()
|
|
103
|
+
} else {
|
|
104
|
+
rule.remove()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
plugin.postcss = true
|
|
111
|
+
module.exports = plugin
|
|
112
|
+
|
|
113
|
+
// ─── Build API — used by CLI ──────────────────────────────────────────
|
|
114
|
+
// Warm builds bypass PostCSS entirely — zero allocation, zero GC pressure
|
|
115
|
+
|
|
116
|
+
module.exports.build = async (inputCSSPath, outputCSSPath, opts = {}) => {
|
|
117
|
+
// ── Warm path: return cached CSS, no work at all ──────────────────
|
|
118
|
+
if (!dirty && cachedCSS) {
|
|
119
|
+
if (outputCSSPath) fs.writeFileSync(outputCSSPath, cachedCSS)
|
|
120
|
+
return cachedCSS
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Cold path: string assembly — no PostCSS parse/stringify ───────
|
|
124
|
+
// PostCSS parse → AST clone → replaceWith → stringify is a costly
|
|
125
|
+
// round-trip that adds no transformation. We replace @strata directives
|
|
126
|
+
// via regex on the raw string, which is O(n) on the input file only.
|
|
127
|
+
const cwd = opts.cwd || process.cwd()
|
|
128
|
+
const config = loadConfig(cwd)
|
|
129
|
+
|
|
130
|
+
const { scanFiles } = require('./scanner/scanner')
|
|
131
|
+
const { generate } = require('./generator/generator')
|
|
132
|
+
|
|
133
|
+
const contentGlobs = config.content || [
|
|
134
|
+
'./src/**/*.{html,jsx,tsx,vue,astro,svelte,js,ts}'
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
const classNames = scanFiles(contentGlobs)
|
|
138
|
+
const { componentCSS, utilityCSS } = generate(classNames, config)
|
|
139
|
+
|
|
140
|
+
// Read input CSS (cached by mtime — strata.css rarely changes)
|
|
141
|
+
const inputCSS = readInputCSS(inputCSSPath)
|
|
142
|
+
|
|
143
|
+
// Replace @strata directives with pre-built CSS strings.
|
|
144
|
+
// Use function replacements (not string literals) so any `$` sequences
|
|
145
|
+
// in the CSS (e.g. inside comments) are never treated as regex back-references.
|
|
146
|
+
const css = inputCSS
|
|
147
|
+
.replace(/^\s*@strata\s+base\s*;/m, () => BASE_CSS)
|
|
148
|
+
.replace(/^\s*@strata\s+components\s*;/m, () => componentCSS || '')
|
|
149
|
+
.replace(/^\s*@strata\s+utilities\s*;/m, () => utilityCSS || '')
|
|
150
|
+
|
|
151
|
+
cachedCSS = css
|
|
152
|
+
dirty = false
|
|
153
|
+
|
|
154
|
+
if (outputCSSPath) {
|
|
155
|
+
fs.mkdirSync(path.dirname(outputCSSPath), { recursive: true })
|
|
156
|
+
fs.writeFileSync(outputCSSPath, css)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return css
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Cache invalidation ───────────────────────────────────────────────
|
|
163
|
+
module.exports.invalidate = (changedFile) => {
|
|
164
|
+
dirty = true
|
|
165
|
+
cachedCSS = null
|
|
166
|
+
const { clearFileCache } = require('./scanner/scanner')
|
|
167
|
+
clearFileCache(changedFile)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Direct PostCSS usage (for users using postcss.config.js) ────────
|
|
171
|
+
// Warm path still applies — cached CSS is injected as a raw parse
|
|
172
|
+
module.exports.postcss = true
|