@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 +97 -86
- package/bin/layout.mjs +386 -0
- package/package.json +19 -7
- package/src/layout.js +96 -0
- package/src/layout.css +0 -24
package/README.md
CHANGED
|
@@ -1,117 +1,128 @@
|
|
|
1
1
|
# Layout
|
|
2
2
|
|
|
3
|
-
A small
|
|
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
|
-
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```sh
|
|
8
|
+
npm install @tobi2409/layout
|
|
9
|
+
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Usage
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
Add the `frame` class to a container and align its direct children:
|
|
12
14
|
|
|
13
15
|
```html
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
34
|
+
Process the HTML file:
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
```sh
|
|
37
|
+
npx layout path/to/application.html
|
|
38
|
+
```
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
<
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
## Alignments
|
|
85
70
|
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
Only direct children of a frame are considered. Frames can be nested without their children affecting the parent frame.
|
|
94
80
|
|
|
95
|
-
|
|
96
|
-
- Flexbox for lists, toolbars, or rows
|
|
97
|
-
- `position: absolute` for overlays or freely positioned elements
|
|
81
|
+
## Responsive alignment
|
|
98
82
|
|
|
99
|
-
|
|
83
|
+
Use a JSON object for breakpoint-specific alignment. Because JSON uses double quotes, wrap the HTML attribute in single quotes:
|
|
100
84
|
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Build-time HTML frame layout generator using CSS Grid",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
30
|
-
"
|
|
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 */
|