@tobi2409/layout 0.5.0 → 0.9.1

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/README.md CHANGED
@@ -1,117 +1,128 @@
1
1
  # Layout
2
2
 
3
- A small Delphi-like layout system based on CSS Grid.
3
+ A small build-time layout system based on CSS Grid. Frame children declare their position with an `@align` HTML attribute; the CLI parses the document and generates the required tracks and placements.
4
4
 
5
- The idea: a container with `.frame` defines fixed areas such as `top`, `left`, `client`, or `bottom`. Child elements are assigned to an area through the CSS custom property `--align`.
5
+ ## Installation
6
6
 
7
- ## Goal
7
+ ```sh
8
+ npm install @tobi2409/layout
9
+ ```
8
10
 
9
- The system is meant to enable simple, readable user interfaces without scattering many utility classes throughout the HTML.
11
+ ## Usage
10
12
 
11
- Example:
13
+ Add the `frame` class to a container and align its direct children:
12
14
 
13
15
  ```html
14
- <div class="frame">
15
- <header style="--align: top;">Header</header>
16
- <nav style="--align: left;">Navigation</nav>
17
- <main style="--align: client;">Inhalt</main>
18
- <aside style="--align: right;">Inspector</aside>
19
- <footer style="--align: bottom;">Footer</footer>
20
- </div>
16
+ <!doctype html>
17
+ <html lang="de">
18
+ <head>
19
+ <meta charset="utf-8">
20
+ <title>Application</title>
21
+ </head>
22
+ <body>
23
+ <div class="frame">
24
+ <header @align="top">Header</header>
25
+ <nav @align="left">Navigation</nav>
26
+ <main @align="client">Content</main>
27
+ <aside @align="right">Inspector</aside>
28
+ <footer @align="bottom">Status</footer>
29
+ </div>
30
+ </body>
31
+ </html>
21
32
  ```
22
33
 
23
- ## Usage
34
+ Process the HTML file:
24
35
 
25
- Include `layout.css`:
36
+ ```sh
37
+ npx layout path/to/application.html
38
+ ```
26
39
 
27
- ```html
28
- <link rel="stylesheet" href="layout.css">
40
+ You can also pass a directory. It is searched recursively for `.html` and `.htm` files; output directories are skipped:
41
+
42
+ ```sh
43
+ npx layout path/to/pages
29
44
  ```
30
45
 
31
- Then create a container with `.frame` and place its direct child elements using `--align`.
32
-
33
- ## Supported align values
34
-
35
- ### Horizontal / vertical
36
-
37
- - `top`
38
- - `top-2`
39
- - `top-3`
40
- - `top-4`
41
- - `top-5`
42
- - `left`
43
- - `left-2`
44
- - `left-3`
45
- - `left-4`
46
- - `left-5`
47
- - `client`
48
- - `right`
49
- - `right-2`
50
- - `right-3`
51
- - `right-4`
52
- - `right-5`
53
- - `bottom`
54
- - `bottom-2`
55
- - `bottom-3`
56
- - `bottom-4`
57
- - `bottom-5`
58
-
59
- `client` is the flexible main area and uses the remaining space.
60
-
61
- ## Basic principle
62
-
63
- - `top...` and `bottom...` occupy full rows.
64
- - `left...` and `right...` occupy columns next to the content.
65
- - `client` is placed in the center.
66
- - Multiple `.frame` containers can be nested.
67
-
68
- ## Nesting example
46
+ The output directory defaults to `generated`. Use `--output-dir` (or `-o`) to choose another name:
47
+
48
+ ```sh
49
+ npx layout path/to/application.html --output-dir dist
50
+ ```
51
+
52
+ For `path/to/application.html`, the command leaves the source unchanged and writes these files:
53
+
54
+ ```text
55
+ path/to/generated/application.html
56
+ path/to/generated/application-layout.css
57
+ path/to/generated/application-layout.js
58
+ ```
59
+
60
+ The generated HTML contains `data-layout-*` attributes instead of `@align`, so it is valid HTML. It also receives the stylesheet and runtime references in its `head` unless they already exist:
69
61
 
70
62
  ```html
71
- <div class="frame" style="height: 300px;">
72
- <header style="--align: top;">Header</header>
73
-
74
- <main class="frame" style="--align: client;">
75
- <div style="--align: top;">Toolbar</div>
76
- <section style="--align: client;">Inhalt</section>
77
- <div style="--align: bottom;">Status</div>
78
- </main>
79
- </div>
63
+ <link rel="stylesheet" href="application-layout.css">
64
+ <script type="module" src="application-layout.js"></script>
80
65
  ```
81
66
 
82
- ## When this works well
67
+ Run the command again whenever the frame structure or an `@align` value changes. It overwrites both generated files deterministically without modifying the source document.
83
68
 
84
- Suitable for:
69
+ ## Alignments
85
70
 
86
- - app layouts
87
- - admin interfaces
88
- - classic desktop-like UI structures
89
- - clearly separated areas such as header, sidebar, content, and footer
71
+ - `top`: adds an `auto` row before the flexible content row
72
+ - `bottom`: adds an `auto` row after the flexible content row
73
+ - `left`: adds an `auto` column before the flexible content column
74
+ - `right`: adds an `auto` column after the flexible content column
75
+ - `client`: occupies the central `1fr` row and column
90
76
 
91
- ## When to combine it with something else
77
+ Repeated values are placed in document order. For example, three `top` children produce `auto auto auto 1fr`; numbered values such as `top-2` are no longer needed.
92
78
 
93
- For dynamic lists or freely flowing rows, `.frame` is not the best level of abstraction. In those cases, a combination is usually better:
79
+ Only direct children of a frame are considered. Frames can be nested without their children affecting the parent frame.
94
80
 
95
- - `.frame` for the coarse page structure
96
- - Flexbox for lists, toolbars, or rows
97
- - `position: absolute` for overlays or freely positioned elements
81
+ ## Responsive alignment
98
82
 
99
- ## Possible future direction
83
+ Use a JSON object for breakpoint-specific alignment. Because JSON uses double quotes, wrap the HTML attribute in single quotes:
100
84
 
101
- It might be worth considering a switch from CSS Grid to Flexbox as the underlying model:
85
+ ```html
86
+ <nav @align='{
87
+ "default": "left",
88
+ "max-width: 599px": "top",
89
+ "min-width: 1200px and max-height: 800px": "right"
90
+ }'>Navigation</nav>
91
+ ```
92
+
93
+ `default` is optional. Every other key becomes a media query. Feature lists can be written without surrounding parentheses; the generator emits valid CSS such as:
94
+
95
+ ```css
96
+ @media (min-width: 1200px) and (max-height: 800px) { /* ... */ }
97
+ ```
98
+
99
+ For each query, all tracks and placements in the affected frame are recalculated. When multiple media queries match simultaneously, normal CSS source order determines the result.
100
+
101
+ ## Runtime API
102
102
 
103
- - `top` / `bottom` → `flex-direction: column` (children stack vertically)
104
- - `left` / `right` → `flex-direction: row` (children stack horizontally)
103
+ The generated runtime exposes `setAlign` globally. Pass a generated layout element and its new alignment:
105
104
 
106
- This would naturally handle multiple children with the same alignment without overlapping, and without needing levels like `top-2` or `left-3`. Dynamic lists and reordering would also become easier. The trade-off is that the current single-class approach with `--align` would need to be rethought, since Flexbox distributes all children at once rather than placing them into named areas.
105
+ ```js
106
+ const inspector = document.querySelector(".inspector");
107
+ setAlign(inspector, "bottom");
108
+ ```
109
+
110
+ `setAlign` updates the element's alignment, recalculates its parent frame's `grid-template-rows` and `grid-template-columns`, and sets `grid-row` and `grid-column` for every aligned child. The runtime value overrides generated responsive placement for that frame until the page is reloaded.
111
+
112
+ The API can also be imported directly:
107
113
 
108
- ## Limitations
114
+ ```js
115
+ import { setAlign } from "@tobi2409/layout/runtime";
116
+ ```
117
+
118
+ Supported runtime values are `top`, `right`, `bottom`, `left`, and `client`. The element must be a direct generated child of a frame.
109
119
 
110
- - Multiple elements with exactly the same `--align` overlap.
111
- - That is why there are levels such as `top-2` or `left-3`.
112
- - The number of levels is currently intentionally limited to 5 per direction.
120
+ ## Development
113
121
 
114
- ## Files
122
+ ```sh
123
+ npm test
124
+ node bin/layout.mjs examples/demo/demo.html
125
+ node bin/layout.mjs examples/list/list.html
126
+ ```
115
127
 
116
- - [layout.css](layout.css): core of the layout system
117
- - [demo.html](demo.html): simple demo example
128
+ The Todo example in `examples/list/list.html` demonstrates how newly appended elements are registered and positioned with `setAlign(todo, "top")`.
package/bin/layout.mjs ADDED
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises'
4
+ import { basename, dirname, extname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { parse, serialize } from 'parse5'
7
+
8
+ const ALIGNMENTS = new Set(['top', 'right', 'bottom', 'left', 'client'])
9
+
10
+ function getAttribute(node, name) {
11
+ return node.attrs?.find((attribute) => attribute.name === name)
12
+ }
13
+
14
+ function setAttribute(node, name, value) {
15
+ const attribute = getAttribute(node, name)
16
+
17
+ if (attribute) {
18
+ attribute.value = value
19
+ } else {
20
+ node.attrs.push({ name, value })
21
+ }
22
+ }
23
+
24
+ function removeAttribute(node, name) {
25
+ node.attrs = node.attrs?.filter((attribute) => attribute.name !== name) ?? []
26
+ }
27
+
28
+ function hasClass(node, className) {
29
+ return getAttribute(node, 'class')?.value.split(/\s+/).includes(className) ?? false
30
+ }
31
+
32
+ function walk(node, visitor) {
33
+ // visitor ist ein Callback, der für jeden DOM-Knoten einmal aufgerufen wird.
34
+ visitor(node)
35
+
36
+ for (const child of node.childNodes ?? []) {
37
+ walk(child, visitor)
38
+ }
39
+ }
40
+
41
+ function parseAlignment(value, source) {
42
+ let parsed = value
43
+
44
+ if (value.trim().startsWith('{')) {
45
+ try {
46
+ parsed = JSON.parse(value)
47
+ } catch (error) {
48
+ throw new Error(`${source}: invalid JSON in @align: ${error.message}`)
49
+ }
50
+ }
51
+
52
+ if (typeof parsed === 'string') {
53
+ validateAlignment(parsed, source)
54
+ return { base: parsed, media: new Map() }
55
+ }
56
+
57
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
58
+ throw new Error(`${source}: @align must be an alignment or a JSON object`);
59
+ }
60
+
61
+ const media = new Map()
62
+ let base
63
+
64
+ for (const [query, alignment] of Object.entries(parsed)) {
65
+ validateAlignment(alignment, source)
66
+
67
+ if (query === 'default') {
68
+ base = alignment
69
+ } else {
70
+ media.set(query, alignment)
71
+ }
72
+ }
73
+
74
+ return { base, media }
75
+ }
76
+
77
+ function validateAlignment(alignment, source) {
78
+ if (!ALIGNMENTS.has(alignment)) {
79
+ throw new Error(`${source}: unsupported alignment "${alignment}"`)
80
+ }
81
+ }
82
+
83
+ function repeat(track, count) {
84
+ return Array.from({ length: count }, () => track)
85
+ }
86
+
87
+ // Ermittelt für einen Frame die im Basis- oder Media-Query-Zustand wirksamen
88
+ // Alignments und erzeugt daraus CSS-Regeln für Grid-Tracks und Elementpositionen.
89
+ // Die Funktion wird beim Verarbeiten der Items einmal für den Basiszustand und
90
+ // zusätzlich für jede definierte Query aufgerufen. query ist dabei nur der Text
91
+ // des zu erzeugenden CSS-Zustands, nicht die aktuell passende Bildschirmgröße;
92
+ // ob eine Media Query zutrifft, entscheidet später der Browser.
93
+ function rulesForState(frameId, items, query) {
94
+ // Löst für jedes Element die aktuell gültige Ausrichtung auf: entweder den
95
+ // Wert der Media Query oder das Basis-Alignment. Elemente ohne wirksames
96
+ // Alignment werden ausgeschlossen.
97
+ const activeItems = items.flatMap((item) => {
98
+ const alignment = query && item.alignment.media.has(query)
99
+ ? item.alignment.media.get(query)
100
+ : item.alignment.base
101
+
102
+ // Beispiel: { id: "1-1", alignment: { base: "client", media: new Map() }, activeAlignment: "client" }
103
+ return alignment ? [{ ...item, activeAlignment: alignment }] : []
104
+ })
105
+
106
+ // Gruppiert die aktiven Elemente nach top, right, bottom, left und client.
107
+ // Die Gruppengrößen bestimmen die Anzahl der Grid-Tracks; die Position eines
108
+ // Elements in seiner Gruppe bestimmt anschließend grid-row bzw. grid-column.
109
+ const groups = Object.fromEntries(
110
+ [...ALIGNMENTS].map((alignment) => [
111
+ alignment,
112
+ activeItems.filter((item) => item.activeAlignment === alignment)
113
+ ])
114
+ )
115
+
116
+ const centerRow = groups.top.length + 1
117
+ const centerColumn = groups.left.length + 1
118
+
119
+ const rows = [...repeat('auto', groups.top.length), '1fr', ...repeat('auto', groups.bottom.length)]
120
+ const columns = [...repeat('auto', groups.left.length), '1fr', ...repeat('auto', groups.right.length)]
121
+
122
+ const rules = [
123
+ `[data-layout-frame='${frameId}'] {`,
124
+ ` grid-template-rows: ${rows.join(' ')};`,
125
+ ` grid-template-columns: ${columns.join(' ')};`,
126
+ "}"
127
+ ]
128
+
129
+ groups.top.forEach((item, index) => rules.push(itemRule(item.id, index + 1, '1 / -1')))
130
+ groups.bottom.forEach((item, index) => rules.push(itemRule(item.id, centerRow + index + 1, '1 / -1')))
131
+ groups.left.forEach((item, index) => rules.push(itemRule(item.id, centerRow, String(index + 1))))
132
+ groups.right.forEach((item, index) => rules.push(itemRule(item.id, centerRow, String(centerColumn + index + 1))))
133
+ groups.client.forEach((item) => rules.push(itemRule(item.id, centerRow, String(centerColumn))))
134
+
135
+ return rules.join('\n');
136
+ }
137
+
138
+ function itemRule(id, row, column) {
139
+ return `[data-layout-item='${id}'] { grid-row: ${row}; grid-column: ${column}; }`
140
+ }
141
+
142
+ function formatMediaQuery(query) {
143
+ const normalized = query.trim().replace(/\s+an\s+/gi, ' and ')
144
+
145
+ if (normalized.startsWith('(')) {
146
+ return normalized
147
+ }
148
+
149
+ return normalized
150
+ .split(/\s+and\s+/i)
151
+ .map((feature) => `(${feature.trim()})`)
152
+ .join(' and ')
153
+ }
154
+
155
+ // Stellt sicher, dass im <head> genau ein Link auf das generierte Stylesheet
156
+ // vorhanden ist. Ein bereits existierender Link wird nicht erneut eingefügt.
157
+ function ensureStylesheet(document, href) {
158
+ let head
159
+ let existing
160
+
161
+ walk(document, (node) => {
162
+ if (node.tagName === 'head') {
163
+ head = node
164
+ }
165
+
166
+ if (node.tagName === 'link' && getAttribute(node, 'href')?.value === href) {
167
+ existing = node;
168
+ }
169
+ })
170
+
171
+ if (existing) {
172
+ return
173
+ }
174
+
175
+ if (!head) {
176
+ throw new Error('HTML document has no <head> element')
177
+ }
178
+
179
+ const fragment = parse(`<link rel="stylesheet" href="${href}">`)
180
+ let link
181
+
182
+ // parse gibt ein DOM-Fragment zurück, daher müssen wir das <link>-Element daraus extrahieren.
183
+ walk(fragment, (node) => {
184
+ if (node.tagName === 'link') {
185
+ link = node
186
+ }
187
+ })
188
+
189
+ link.parentNode = head
190
+ head.childNodes.push(link)
191
+ }
192
+
193
+ // Stellt sicher, dass im <head> genau ein Modul-Script für die Runtime-API
194
+ // vorhanden ist. Ein bereits existierendes Script wird nicht erneut eingefügt.
195
+ function ensureRuntime(document, src) {
196
+ let head
197
+ let existing
198
+
199
+ walk(document, (node) => {
200
+ if (node.tagName === 'head') {
201
+ head = node
202
+ }
203
+
204
+ if (node.tagName === 'script' && getAttribute(node, 'src')?.value === src) {
205
+ existing = node
206
+ }
207
+ })
208
+
209
+ if (existing) {
210
+ return
211
+ }
212
+
213
+ if (!head) {
214
+ throw new Error('HTML document has no <head> element')
215
+ }
216
+
217
+ const fragment = parse(`<script type="module" src="${src}"></script>`)
218
+ let script
219
+
220
+ walk(fragment, (node) => {
221
+ if (node.tagName === 'script') {
222
+ script = node
223
+ }
224
+ })
225
+
226
+ script.parentNode = head
227
+ head.childNodes.push(script)
228
+ }
229
+
230
+ export function compileHtml(source, sourceName = 'document.html') {
231
+ const document = parse(source)
232
+ const frames = []
233
+
234
+ walk(document, (node) => {
235
+ if (node.tagName && hasClass(node, 'frame')) {
236
+ frames.push(node)
237
+ }
238
+ })
239
+
240
+ const css = [
241
+ '.frame {',
242
+ ' position: relative;',
243
+ ' display: grid;',
244
+ '}'
245
+ ]
246
+
247
+ frames.forEach((frame, frameIndex) => {
248
+ const frameId = String(frameIndex + 1)
249
+ setAttribute(frame, 'data-layout-frame', frameId)
250
+
251
+ const items = []
252
+
253
+ for (const child of frame.childNodes ?? []) {
254
+ const attribute = getAttribute(child, '@align')
255
+
256
+ if (!attribute) {
257
+ continue
258
+ }
259
+
260
+ const id = `${frameId}-${items.length + 1}`
261
+
262
+ setAttribute(child, 'data-layout-item', id)
263
+ setAttribute(child, 'data-layout-align', attribute.value)
264
+
265
+ // alignment kommt nicht aus dem DOM: parseAlignment wandelt den rohen
266
+ // @align-Attributwert in { base, media } für die CSS-Erzeugung um.
267
+ items.push({ id, alignment: parseAlignment(attribute.value, sourceName) })
268
+ removeAttribute(child, '@align')
269
+ }
270
+
271
+ css.push('', rulesForState(frameId, items))
272
+
273
+ const queries = new Set(items.flatMap((item) => [...item.alignment.media.keys()]))
274
+
275
+ for (const query of queries) {
276
+ const mediaRules = rulesForState(frameId, items, query)
277
+ .split('\n')
278
+ .map((line) => ` ${line}`)
279
+ .join('\n')
280
+
281
+ css.push('', `@media ${formatMediaQuery(query)} {\n${mediaRules}\n}`)
282
+ }
283
+ })
284
+
285
+ const extension = extname(sourceName)
286
+ const stem = basename(sourceName, extension)
287
+ const stylesheet = `${stem}-layout.css`
288
+ const runtime = `${stem}-layout.js`
289
+
290
+ ensureStylesheet(document, stylesheet)
291
+ ensureRuntime(document, runtime)
292
+
293
+ return { html: serialize(document), css: `${css.join('\n')}\n`, stylesheet, runtime }
294
+ }
295
+
296
+ export async function compileFile(fileName, outputDirectoryName = 'generated') {
297
+ const source = await readFile(fileName, 'utf8')
298
+ const result = compileHtml(source, fileName)
299
+ const outputDirectory = join(dirname(fileName), outputDirectoryName)
300
+ const htmlFile = join(outputDirectory, basename(fileName))
301
+ const cssFile = join(outputDirectory, result.stylesheet)
302
+ const runtimeFile = join(outputDirectory, result.runtime)
303
+ const runtimeSource = await readFile(new URL('../src/layout.js', import.meta.url), 'utf8')
304
+
305
+ await mkdir(outputDirectory, { recursive: true })
306
+
307
+ await Promise.all([
308
+ writeFile(htmlFile, result.html),
309
+ writeFile(cssFile, result.css),
310
+ writeFile(runtimeFile, runtimeSource)
311
+ ])
312
+
313
+ return { ...result, htmlFile, cssFile, runtimeFile }
314
+ }
315
+
316
+ async function findHtmlFiles(directory, outputDirectoryName) {
317
+ const files = []
318
+
319
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
320
+ const entryPath = join(directory, entry.name)
321
+
322
+ if (entry.isDirectory()) {
323
+ if (entry.name !== outputDirectoryName) {
324
+ files.push(...await findHtmlFiles(entryPath, outputDirectoryName))
325
+ }
326
+ } else if (entry.isFile() && /\.html?$/i.test(entry.name)) {
327
+ files.push(entryPath)
328
+ }
329
+ }
330
+
331
+ return files
332
+ }
333
+
334
+ export async function compilePath(inputPath, outputDirectoryName = 'generated') {
335
+ const input = await stat(inputPath)
336
+ const files = input.isDirectory()
337
+ ? await findHtmlFiles(inputPath, outputDirectoryName)
338
+ : [inputPath]
339
+
340
+ return Promise.all(files.map((fileName) => compileFile(fileName, outputDirectoryName)))
341
+ }
342
+
343
+ async function main() {
344
+ const args = process.argv.slice(2)
345
+ const fileName = args[0]
346
+ let outputDirectoryName = 'generated'
347
+
348
+ if (!fileName) {
349
+ console.error('Usage: layout <document.html|directory> [--output-dir <name>]')
350
+ process.exitCode = 1
351
+ return
352
+ }
353
+
354
+ for (let index = 1; index < args.length; index += 1) {
355
+ const argument = args[index]
356
+
357
+ if (argument === '--output-dir' || argument === '-o') {
358
+ outputDirectoryName = args[index + 1]
359
+ index += 1
360
+
361
+ if (!outputDirectoryName) {
362
+ throw new Error(`${argument} requires a directory name`)
363
+ }
364
+ } else {
365
+ throw new Error(`Unknown argument: ${argument}`)
366
+ }
367
+ }
368
+
369
+ const results = await compilePath(fileName, outputDirectoryName)
370
+
371
+ for (const result of results) {
372
+ console.log(`Generated ${result.htmlFile}`)
373
+ console.log(`Generated ${result.cssFile}`)
374
+ console.log(`Generated ${result.runtimeFile}`)
375
+ }
376
+ }
377
+
378
+ const isMainModule = process.argv[1]
379
+ && await realpath(process.argv[1]) === await realpath(fileURLToPath(import.meta.url))
380
+
381
+ if (isMainModule) {
382
+ main().catch((error) => {
383
+ console.error(error.message)
384
+ process.exitCode = 1
385
+ })
386
+ }
package/package.json CHANGED
@@ -1,14 +1,23 @@
1
1
  {
2
2
  "name": "@tobi2409/layout",
3
- "version": "0.5.0",
4
- "description": "Tiny CSS frame layout using named grid areas and the --align property",
3
+ "version": "0.9.1",
4
+ "description": "Build-time HTML frame layout generator using CSS Grid",
5
5
  "license": "MIT",
6
- "main": "src/layout.css",
6
+ "type": "module",
7
+ "exports": {
8
+ "./runtime": "./src/layout.js"
9
+ },
10
+ "bin": {
11
+ "layout": "bin/layout.mjs"
12
+ },
7
13
  "files": [
14
+ "bin",
8
15
  "src",
9
16
  "README.md"
10
17
  ],
11
- "style": "src/layout.css",
18
+ "scripts": {
19
+ "test": "node --test"
20
+ },
12
21
  "keywords": [
13
22
  "css",
14
23
  "css-library",
@@ -26,8 +35,8 @@
26
35
  "frontend",
27
36
  "responsive",
28
37
  "align",
29
- "grid-area",
30
- "named-areas",
38
+ "html-attributes",
39
+ "build-time",
31
40
  "client-area",
32
41
  "top",
33
42
  "bottom",
@@ -40,5 +49,8 @@
40
49
  ],
41
50
  "publishConfig": {
42
51
  "access": "public"
52
+ },
53
+ "dependencies": {
54
+ "parse5": "^8.0.1"
43
55
  }
44
- }
56
+ }
package/src/layout.js ADDED
@@ -0,0 +1,96 @@
1
+ const ALIGNMENTS = new Set(['top', 'right', 'bottom', 'left', 'client'])
2
+ let runtimeItemId = 0
3
+
4
+ function validateAlignment(alignment) {
5
+ if (!ALIGNMENTS.has(alignment)) {
6
+ throw new TypeError(`Unsupported alignment "${alignment}"`)
7
+ }
8
+ }
9
+
10
+ function formatMediaQuery(query) {
11
+ const normalized = query.trim().replace(/\s+an\s+/gi, ' and ')
12
+
13
+ if (normalized.startsWith('(')) {
14
+ return normalized
15
+ }
16
+
17
+ return normalized
18
+ .split(/\s+and\s+/i)
19
+ .map((feature) => `(${feature.trim()})`)
20
+ .join(' and ')
21
+ }
22
+
23
+ function getAlignment(element) {
24
+ const value = element.dataset.layoutAlign
25
+
26
+ if (!value?.trim().startsWith('{')) {
27
+ return value
28
+ }
29
+
30
+ const specification = JSON.parse(value)
31
+ let alignment = specification.default
32
+
33
+ for (const [query, candidate] of Object.entries(specification)) {
34
+ if (query !== 'default' && globalThis.matchMedia(formatMediaQuery(query)).matches) {
35
+ alignment = candidate
36
+ }
37
+ }
38
+
39
+ return alignment
40
+ }
41
+
42
+ function place(element, row, column) {
43
+ element.style.gridRow = String(row)
44
+ element.style.gridColumn = String(column)
45
+ }
46
+
47
+ function updateFrame(frame) {
48
+ const groups = Object.fromEntries([...ALIGNMENTS].map((alignment) => [alignment, []]))
49
+ const items = [...frame.children].filter((child) => child.dataset.layoutItem && child.dataset.layoutAlign)
50
+
51
+ for (const item of items) {
52
+ const alignment = getAlignment(item)
53
+
54
+ if (alignment) {
55
+ validateAlignment(alignment)
56
+ groups[alignment].push(item)
57
+ }
58
+
59
+ item.style.gridRow = ''
60
+ item.style.gridColumn = ''
61
+ }
62
+
63
+ const centerRow = groups.top.length + 1
64
+ const centerColumn = groups.left.length + 1
65
+
66
+ frame.style.gridTemplateRows = [...groups.top.map(() => 'auto'), '1fr', ...groups.bottom.map(() => 'auto')].join(' ')
67
+ frame.style.gridTemplateColumns = [...groups.left.map(() => 'auto'), '1fr', ...groups.right.map(() => 'auto')].join(' ')
68
+
69
+ groups.top.forEach((item, index) => place(item, index + 1, '1 / -1'))
70
+ groups.bottom.forEach((item, index) => place(item, centerRow + index + 1, '1 / -1'))
71
+ groups.left.forEach((item, index) => place(item, centerRow, index + 1))
72
+ groups.right.forEach((item, index) => place(item, centerRow, centerColumn + index + 1))
73
+ groups.client.forEach((item) => place(item, centerRow, centerColumn))
74
+ }
75
+
76
+ export function setAlign(element, alignment) {
77
+ validateAlignment(alignment)
78
+
79
+ const frame = element?.parentElement
80
+
81
+ if (!frame?.dataset.layoutFrame) {
82
+ throw new TypeError("setAlign expects a direct child of a generated frame")
83
+ }
84
+
85
+ if (!element.dataset.layoutItem) {
86
+ runtimeItemId += 1
87
+ element.dataset.layoutItem = `${frame.dataset.layoutFrame}-runtime-${runtimeItemId}`
88
+ }
89
+
90
+ element.dataset.layoutAlign = alignment
91
+ updateFrame(frame)
92
+
93
+ return element
94
+ }
95
+
96
+ globalThis.setAlign = setAlign
package/src/layout.css DELETED
@@ -1,24 +0,0 @@
1
- .frame {
2
- position: relative;
3
- display: grid;
4
- grid-template-rows: auto auto auto auto auto 1fr auto auto auto auto auto; /* top / client / bottom */
5
- grid-template-columns: auto auto auto auto auto 1fr auto auto auto auto auto; /* left / client / right */
6
- grid-template-areas:
7
- "top top top top top top top top top top top"
8
- "top-2 top-2 top-2 top-2 top-2 top-2 top-2 top-2 top-2 top-2 top-2"
9
- "top-3 top-3 top-3 top-3 top-3 top-3 top-3 top-3 top-3 top-3 top-3"
10
- "top-4 top-4 top-4 top-4 top-4 top-4 top-4 top-4 top-4 top-4 top-4"
11
- "top-5 top-5 top-5 top-5 top-5 top-5 top-5 top-5 top-5 top-5 top-5"
12
- "left left-2 left-3 left-4 left-5 client right right-2 right-3 right-4 right-5"
13
- "bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom"
14
- "bottom-2 bottom-2 bottom-2 bottom-2 bottom-2 bottom-2 bottom-2 bottom-2 bottom-2 bottom-2 bottom-2"
15
- "bottom-3 bottom-3 bottom-3 bottom-3 bottom-3 bottom-3 bottom-3 bottom-3 bottom-3 bottom-3 bottom-3"
16
- "bottom-4 bottom-4 bottom-4 bottom-4 bottom-4 bottom-4 bottom-4 bottom-4 bottom-4 bottom-4 bottom-4"
17
- "bottom-5 bottom-5 bottom-5 bottom-5 bottom-5 bottom-5 bottom-5 bottom-5 bottom-5 bottom-5 bottom-5";
18
- }
19
-
20
- .frame > * {
21
- grid-area: var(--align, auto);
22
- }
23
-
24
- /* soll sich Element frei im Parent bewegen, einfach absolute Positionierung */