@newlogic-digital/cli 1.5.0-next.5 → 1.5.0-next.7
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/index.mjs +5 -53
- package/package.json +1 -1
- package/skills/newlogic-cli/SKILL.md +23 -5
- package/src/cli/args.mjs +49 -0
- package/src/commands/blocks/index.mjs +48 -6
- package/src/commands/blocks/service.mjs +183 -62
- package/src/templates/components/article/ArticleGridHorizontal.latte +0 -28
- package/src/templates/components/article/ArticleItem.latte +0 -59
- package/src/templates/components/header/HeaderNavLeft.latte +0 -71
package/index.mjs
CHANGED
|
@@ -4,15 +4,12 @@ import init from './src/commands/init/index.mjs'
|
|
|
4
4
|
import cms from './src/commands/cms/index.mjs'
|
|
5
5
|
import blocks from './src/commands/blocks/index.mjs'
|
|
6
6
|
import skills from './src/commands/skills/index.mjs'
|
|
7
|
+
import { parseCommandArgs } from './src/cli/args.mjs'
|
|
7
8
|
import { styleText } from 'node:util'
|
|
8
9
|
import { version, name } from './src/utils.mjs'
|
|
9
10
|
|
|
10
11
|
const knownCommands = ['init', 'cms', 'blocks', 'skills']
|
|
11
12
|
|
|
12
|
-
function normalizeOptionName(name) {
|
|
13
|
-
return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
14
|
-
}
|
|
15
|
-
|
|
16
13
|
function getLevenshteinDistance(left, right) {
|
|
17
14
|
const distances = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0))
|
|
18
15
|
|
|
@@ -73,52 +70,6 @@ function printUnknownCommand(command) {
|
|
|
73
70
|
console.log(lines.join('\n'))
|
|
74
71
|
}
|
|
75
72
|
|
|
76
|
-
function parseCommandArgs(args) {
|
|
77
|
-
const positionals = []
|
|
78
|
-
const options = {}
|
|
79
|
-
|
|
80
|
-
for (let i = 0; i < args.length; i++) {
|
|
81
|
-
const arg = args[i]
|
|
82
|
-
|
|
83
|
-
if (arg === '-y') {
|
|
84
|
-
options.y = true
|
|
85
|
-
continue
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!arg.startsWith('--')) {
|
|
89
|
-
positionals.push(arg)
|
|
90
|
-
continue
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (arg.startsWith('--no-')) {
|
|
94
|
-
options[normalizeOptionName(arg.slice(5))] = false
|
|
95
|
-
continue
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (arg.includes('=')) {
|
|
99
|
-
const splitIndex = arg.indexOf('=')
|
|
100
|
-
const key = normalizeOptionName(arg.slice(2, splitIndex))
|
|
101
|
-
const value = arg.slice(splitIndex + 1)
|
|
102
|
-
|
|
103
|
-
options[key] = value || true
|
|
104
|
-
continue
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const key = normalizeOptionName(arg.slice(2))
|
|
108
|
-
const nextArg = args[i + 1]
|
|
109
|
-
|
|
110
|
-
if (nextArg && !nextArg.startsWith('-')) {
|
|
111
|
-
options[key] = nextArg
|
|
112
|
-
i++
|
|
113
|
-
continue
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
options[key] = true
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return { positionals, options }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
73
|
const rawArgs = process.argv.slice(2)
|
|
123
74
|
const command = rawArgs[0]
|
|
124
75
|
|
|
@@ -150,12 +101,13 @@ if (!command) {
|
|
|
150
101
|
|
|
151
102
|
-- blocks --
|
|
152
103
|
${styleText('green', 'newlogic blocks list')} - Lists all available installable blocks with descriptions
|
|
153
|
-
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', '<name[@variant]...>')} - Installs one or more blocks by kebab-case or PascalCase name
|
|
104
|
+
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', '<name[@variant]...>')} ${styleText('cyan', '[--target=<ui|cms>]')} - Installs one or more blocks by kebab-case or PascalCase name
|
|
154
105
|
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies
|
|
155
106
|
${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}
|
|
156
107
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'about-accordion')}
|
|
157
108
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'AboutAccordion')}
|
|
158
109
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left@stimulus')}
|
|
110
|
+
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card')} ${styleText('cyan', '--target=ui')}
|
|
159
111
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}
|
|
160
112
|
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'about-accordion')}
|
|
161
113
|
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}
|
|
@@ -185,11 +137,11 @@ if (command === 'cms') {
|
|
|
185
137
|
}
|
|
186
138
|
|
|
187
139
|
if (command === 'blocks') {
|
|
188
|
-
const { positionals } = parseCommandArgs(rawArgs.slice(1))
|
|
140
|
+
const { positionals, options } = parseCommandArgs(rawArgs.slice(1))
|
|
189
141
|
const action = positionals[0]
|
|
190
142
|
const names = positionals.slice(1)
|
|
191
143
|
|
|
192
|
-
await blocks(action, names)
|
|
144
|
+
await blocks(action, names, options)
|
|
193
145
|
}
|
|
194
146
|
|
|
195
147
|
if (command === 'skills') {
|
package/package.json
CHANGED
|
@@ -60,23 +60,41 @@ Notes:
|
|
|
60
60
|
Use to manage installable blocks in a project root.
|
|
61
61
|
|
|
62
62
|
- `newlogic blocks list`
|
|
63
|
-
- `newlogic blocks add <name
|
|
63
|
+
- `newlogic blocks add <name[@variant]...> [--target=<ui|cms>]`
|
|
64
64
|
- `newlogic blocks remove <name...>`
|
|
65
65
|
- `newlogic blocks update`
|
|
66
66
|
|
|
67
67
|
Notes:
|
|
68
68
|
|
|
69
69
|
- `list` prints all installable blocks with short descriptions so the agent can discover valid names before making changes.
|
|
70
|
-
- `add` accepts kebab-case or PascalCase block names
|
|
71
|
-
-
|
|
72
|
-
- `
|
|
70
|
+
- `add` accepts kebab-case or PascalCase block names and optional `@variant`.
|
|
71
|
+
- `--target` is a batch option for the whole `add` command. `newlogic blocks add blok-1@stimulus blok-2 --target=ui` applies `target: "ui"` to both root blocks.
|
|
72
|
+
- Use `--target=ui` when you want to copy only the frontend block files into a project where the CMS part does not exist yet.
|
|
73
|
+
- Use `--target=cms` when the project already has the UI frontend and UI block files, but is still missing the backend/CMS implementation for those blocks.
|
|
74
|
+
- Installed blocks are recorded in `newlogic.config.json` as an object map keyed by block name.
|
|
75
|
+
- `update` reinstalls all configured blocks from `newlogic.config.json`, including stored `variant` and `target`.
|
|
76
|
+
- When a block metadata file rule defines `target`, only matching files are installed for that block config. Without `target`, all files are installed.
|
|
73
77
|
- `add` and `remove` may modify files and install npm or composer dependencies.
|
|
74
78
|
|
|
79
|
+
Current config format:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"blocks": {
|
|
84
|
+
"about-horizontal": {
|
|
85
|
+
"variant": "stimulus",
|
|
86
|
+
"target": "ui"
|
|
87
|
+
},
|
|
88
|
+
"hero-floating-text": {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
75
93
|
Recommended flow:
|
|
76
94
|
|
|
77
95
|
1. Run `newlogic blocks list` to discover what blocks exist.
|
|
78
96
|
2. Match the user request to one or more listed block names.
|
|
79
|
-
3. Run `newlogic blocks add <name...>` only after the names are confirmed by the list output.
|
|
97
|
+
3. Run `newlogic blocks add <name[@variant]...>` only after the names are confirmed by the list output.
|
|
80
98
|
4. Use `remove` only when the user wants to delete blocks that are already installed in the current project.
|
|
81
99
|
5. Use `update` when the goal is to reinstall everything already tracked in `newlogic.config.json`.
|
|
82
100
|
|
package/src/cli/args.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function normalizeOptionName(name) {
|
|
2
|
+
return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function parseCommandArgs(args) {
|
|
6
|
+
const positionals = []
|
|
7
|
+
const options = {}
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const arg = args[i]
|
|
11
|
+
|
|
12
|
+
if (arg === '-y') {
|
|
13
|
+
options.y = true
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!arg.startsWith('--')) {
|
|
18
|
+
positionals.push(arg)
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (arg.startsWith('--no-')) {
|
|
23
|
+
options[normalizeOptionName(arg.slice(5))] = false
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (arg.includes('=')) {
|
|
28
|
+
const splitIndex = arg.indexOf('=')
|
|
29
|
+
const key = normalizeOptionName(arg.slice(2, splitIndex))
|
|
30
|
+
const value = arg.slice(splitIndex + 1)
|
|
31
|
+
|
|
32
|
+
options[key] = value || true
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const key = normalizeOptionName(arg.slice(2))
|
|
37
|
+
const nextArg = args[i + 1]
|
|
38
|
+
|
|
39
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
40
|
+
options[key] = nextArg
|
|
41
|
+
i++
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
options[key] = true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { positionals, options }
|
|
49
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spinner as createSpinner } from '@clack/prompts'
|
|
1
2
|
import { styleText } from 'node:util'
|
|
2
3
|
import { createBlocksService } from './service.mjs'
|
|
3
4
|
|
|
@@ -39,6 +40,7 @@ function formatErrorMessage(message) {
|
|
|
39
40
|
function createSilentLogger() {
|
|
40
41
|
return {
|
|
41
42
|
info() {},
|
|
43
|
+
progress() {},
|
|
42
44
|
warn() {},
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -100,14 +102,45 @@ function formatInfoMessage(message) {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
function createCliLogger() {
|
|
105
|
+
const progress = createSpinner()
|
|
106
|
+
let hasActiveProgress = false
|
|
107
|
+
|
|
108
|
+
function clearProgress() {
|
|
109
|
+
if (!hasActiveProgress) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
progress.clear()
|
|
114
|
+
hasActiveProgress = false
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
return {
|
|
118
|
+
progress(message) {
|
|
119
|
+
if (!message) {
|
|
120
|
+
clearProgress()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!hasActiveProgress) {
|
|
125
|
+
progress.start(message)
|
|
126
|
+
hasActiveProgress = true
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
progress.message(message)
|
|
131
|
+
},
|
|
132
|
+
|
|
104
133
|
info(message) {
|
|
134
|
+
clearProgress()
|
|
105
135
|
console.log(formatInfoMessage(message))
|
|
106
136
|
},
|
|
107
137
|
|
|
108
138
|
warn(message) {
|
|
139
|
+
clearProgress()
|
|
109
140
|
console.log(`${label('yellow', 'warn')} ${styleText('yellow', highlightTokens(message))}`)
|
|
110
141
|
},
|
|
142
|
+
|
|
143
|
+
clearProgress,
|
|
111
144
|
}
|
|
112
145
|
}
|
|
113
146
|
|
|
@@ -134,7 +167,7 @@ function printBlocksUsage() {
|
|
|
134
167
|
styleText(['white', 'bold'], 'Usage:'),
|
|
135
168
|
'',
|
|
136
169
|
` ${styleText('green', 'newlogic blocks list')} - Lists all available blocks with descriptions`,
|
|
137
|
-
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', '<name[@variant]...>')} - Adds one or more blocks by kebab-case or PascalCase name`,
|
|
170
|
+
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', '<name[@variant]...>')} ${styleText('cyan', '[--target=<ui|cms>]')} - Adds one or more blocks by kebab-case or PascalCase name`,
|
|
138
171
|
` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies`,
|
|
139
172
|
` ${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}`,
|
|
140
173
|
'',
|
|
@@ -142,11 +175,14 @@ function printBlocksUsage() {
|
|
|
142
175
|
'',
|
|
143
176
|
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}`,
|
|
144
177
|
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left@stimulus')}`,
|
|
178
|
+
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card')} ${styleText('cyan', '--target=ui')}`,
|
|
145
179
|
` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}`,
|
|
146
180
|
].join('\n'))
|
|
147
181
|
}
|
|
148
182
|
|
|
149
|
-
export default async function blocks(action, names = []) {
|
|
183
|
+
export default async function blocks(action, names = [], options = {}) {
|
|
184
|
+
let activeLogger
|
|
185
|
+
|
|
150
186
|
if (!action || action === 'help' || action === '--help') {
|
|
151
187
|
printBlocksUsage()
|
|
152
188
|
return
|
|
@@ -166,8 +202,11 @@ export default async function blocks(action, names = []) {
|
|
|
166
202
|
throw new Error('Missing block name for "newlogic blocks add"')
|
|
167
203
|
}
|
|
168
204
|
|
|
169
|
-
|
|
170
|
-
|
|
205
|
+
activeLogger = createCliLogger()
|
|
206
|
+
const service = createBlocksService({ logger: activeLogger })
|
|
207
|
+
await service.addBlocks(names, {
|
|
208
|
+
target: options.target,
|
|
209
|
+
})
|
|
171
210
|
return
|
|
172
211
|
}
|
|
173
212
|
|
|
@@ -176,13 +215,15 @@ export default async function blocks(action, names = []) {
|
|
|
176
215
|
throw new Error('Missing block name for "newlogic blocks remove"')
|
|
177
216
|
}
|
|
178
217
|
|
|
179
|
-
|
|
218
|
+
activeLogger = createCliLogger()
|
|
219
|
+
const service = createBlocksService({ logger: activeLogger })
|
|
180
220
|
await service.removeBlocks(names)
|
|
181
221
|
return
|
|
182
222
|
}
|
|
183
223
|
|
|
184
224
|
if (action === 'update') {
|
|
185
|
-
|
|
225
|
+
activeLogger = createCliLogger()
|
|
226
|
+
const service = createBlocksService({ logger: activeLogger })
|
|
186
227
|
await service.updateBlocks()
|
|
187
228
|
return
|
|
188
229
|
}
|
|
@@ -190,6 +231,7 @@ export default async function blocks(action, names = []) {
|
|
|
190
231
|
throw new Error(`Unknown blocks action "${action}"`)
|
|
191
232
|
}
|
|
192
233
|
catch (error) {
|
|
234
|
+
activeLogger?.clearProgress?.()
|
|
193
235
|
console.log(`${label('red', 'error')} ${formatErrorMessage(error.message)}`)
|
|
194
236
|
process.exit(1)
|
|
195
237
|
}
|
|
@@ -8,10 +8,12 @@ import { createRemoteBlocksRepository } from './repository.mjs'
|
|
|
8
8
|
|
|
9
9
|
const CONFIG_FILE_NAME = 'newlogic.config.json'
|
|
10
10
|
const BLOCK_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
11
|
+
const BLOCK_TARGETS = new Set(['ui', 'cms'])
|
|
11
12
|
|
|
12
13
|
function defaultLogger() {
|
|
13
14
|
return {
|
|
14
15
|
info: message => console.log(message),
|
|
16
|
+
progress() {},
|
|
15
17
|
warn: message => console.warn(message),
|
|
16
18
|
}
|
|
17
19
|
}
|
|
@@ -83,27 +85,27 @@ function assertBlockName(name, label = 'block name') {
|
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
if (allowObject && rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
|
|
91
|
-
const name = normalizeBlockName(rawValue.name)
|
|
92
|
-
const variant = rawValue.variant == null ? '' : `${rawValue.variant}`.trim()
|
|
88
|
+
function normalizeBlockTarget(rawValue, label = 'block target') {
|
|
89
|
+
if (rawValue == null) {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
93
92
|
|
|
94
|
-
|
|
93
|
+
const target = `${rawValue}`.trim().toLowerCase()
|
|
95
94
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
if (!target) {
|
|
96
|
+
throw new Error(`Invalid ${label}: "${rawValue}"`)
|
|
97
|
+
}
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
variant: variant || undefined,
|
|
103
|
-
variantExplicit: rawValue.variant != null,
|
|
104
|
-
}
|
|
99
|
+
if (!BLOCK_TARGETS.has(target)) {
|
|
100
|
+
throw new Error(`Invalid ${label}: "${target}"`)
|
|
105
101
|
}
|
|
106
102
|
|
|
103
|
+
return target
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseBlockSpecifier(rawValue, {
|
|
107
|
+
label = 'block name',
|
|
108
|
+
} = {}) {
|
|
107
109
|
const value = `${rawValue ?? ''}`.trim()
|
|
108
110
|
|
|
109
111
|
if (!value) {
|
|
@@ -124,7 +126,6 @@ function parseBlockSpecifier(rawValue, {
|
|
|
124
126
|
return {
|
|
125
127
|
name,
|
|
126
128
|
variant: rawVariant || undefined,
|
|
127
|
-
variantExplicit: variantSeparator !== -1,
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
@@ -132,6 +133,54 @@ function toBlockKey(block) {
|
|
|
132
133
|
return `${block.name}@${block.variant}`
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
function createTraversalKey(name, variant, target) {
|
|
137
|
+
return `${name}@${variant}#${target || '*'}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createConfiguredBlock(name, config = {}) {
|
|
141
|
+
const nextConfig = { ...config }
|
|
142
|
+
|
|
143
|
+
if (nextConfig.variant != null) {
|
|
144
|
+
const variant = `${nextConfig.variant}`.trim()
|
|
145
|
+
|
|
146
|
+
if (!variant) {
|
|
147
|
+
throw new Error(`Configured block "${name}" has an empty variant`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
nextConfig.variant = variant
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (nextConfig.target != null) {
|
|
154
|
+
nextConfig.target = normalizeBlockTarget(nextConfig.target, `configured block "${name}" target`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
variant: nextConfig.variant,
|
|
160
|
+
target: nextConfig.target,
|
|
161
|
+
config: nextConfig,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function filterRulesByTargets(rules = [], targets = []) {
|
|
166
|
+
const targetSet = new Set(targets)
|
|
167
|
+
|
|
168
|
+
if (targetSet.has(undefined)) {
|
|
169
|
+
return rules
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return rules.filter(rule => rule.target && targetSet.has(rule.target))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toManagedFileKey(rule) {
|
|
176
|
+
return [
|
|
177
|
+
rule.source,
|
|
178
|
+
rule.destination,
|
|
179
|
+
rule.parentDirectory || '',
|
|
180
|
+
rule.fileName,
|
|
181
|
+
].join('\0')
|
|
182
|
+
}
|
|
183
|
+
|
|
135
184
|
function resolveTargetPath(projectRoot, rule) {
|
|
136
185
|
const destinationRoot = resolveInside(projectRoot, rule.destination)
|
|
137
186
|
const parentDirectory = rule.parentDirectory ? rule.parentDirectory.split('/').filter(Boolean) : []
|
|
@@ -171,7 +220,7 @@ function loadProjectConfig(projectRoot) {
|
|
|
171
220
|
if (!fs.existsSync(configPath)) {
|
|
172
221
|
return {
|
|
173
222
|
configPath,
|
|
174
|
-
config: { blocks:
|
|
223
|
+
config: { blocks: {} },
|
|
175
224
|
blocks: [],
|
|
176
225
|
}
|
|
177
226
|
}
|
|
@@ -189,36 +238,42 @@ function loadProjectConfig(projectRoot) {
|
|
|
189
238
|
throw new Error(`${CONFIG_FILE_NAME} must contain a JSON object`)
|
|
190
239
|
}
|
|
191
240
|
|
|
192
|
-
const blocksRaw = config.blocks ??
|
|
241
|
+
const blocksRaw = config.blocks ?? {}
|
|
193
242
|
|
|
194
|
-
if (!Array.isArray(blocksRaw)) {
|
|
195
|
-
throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an
|
|
243
|
+
if (!blocksRaw || typeof blocksRaw !== 'object' || Array.isArray(blocksRaw)) {
|
|
244
|
+
throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an object`)
|
|
196
245
|
}
|
|
197
246
|
|
|
198
|
-
const blocks = blocksRaw.map((
|
|
199
|
-
|
|
200
|
-
|
|
247
|
+
const blocks = Object.entries(blocksRaw).map(([rawName, rawConfig]) => {
|
|
248
|
+
const name = normalizeBlockName(rawName)
|
|
249
|
+
|
|
250
|
+
assertBlockName(name, 'configured block name')
|
|
251
|
+
|
|
252
|
+
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
|
|
253
|
+
throw new Error(`Configured block "${name}" must use an object value`)
|
|
201
254
|
}
|
|
202
255
|
|
|
203
|
-
return
|
|
204
|
-
label: `block config entry at index ${index}`,
|
|
205
|
-
allowObject: true,
|
|
206
|
-
})
|
|
256
|
+
return createConfiguredBlock(name, rawConfig)
|
|
207
257
|
})
|
|
208
258
|
|
|
209
|
-
return { configPath, config, blocks
|
|
259
|
+
return { configPath, config, blocks }
|
|
210
260
|
}
|
|
211
261
|
|
|
212
262
|
function saveProjectConfig(configPath, config, blocks) {
|
|
213
263
|
const nextConfig = {
|
|
214
264
|
...config,
|
|
215
|
-
blocks:
|
|
265
|
+
blocks: Object.fromEntries(
|
|
266
|
+
blocks.map(block => [
|
|
267
|
+
block.name,
|
|
268
|
+
{ ...block.config },
|
|
269
|
+
]),
|
|
270
|
+
),
|
|
216
271
|
}
|
|
217
272
|
|
|
218
273
|
fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`)
|
|
219
274
|
}
|
|
220
275
|
|
|
221
|
-
function
|
|
276
|
+
function dedupeBlocks(blocks) {
|
|
222
277
|
const byName = new Map()
|
|
223
278
|
|
|
224
279
|
for (const block of blocks) {
|
|
@@ -239,7 +294,10 @@ function normalizeRequestedBlockNames(rawNames, label = 'block name') {
|
|
|
239
294
|
return [...new Set(normalizedNames)]
|
|
240
295
|
}
|
|
241
296
|
|
|
242
|
-
function normalizeRequestedBlocks(rawNames,
|
|
297
|
+
function normalizeRequestedBlocks(rawNames, {
|
|
298
|
+
label = 'block name',
|
|
299
|
+
target,
|
|
300
|
+
} = {}) {
|
|
243
301
|
if (!Array.isArray(rawNames) || rawNames.length === 0) {
|
|
244
302
|
throw new Error(`Missing ${label}`)
|
|
245
303
|
}
|
|
@@ -248,12 +306,25 @@ function normalizeRequestedBlocks(rawNames, label = 'block name') {
|
|
|
248
306
|
const byName = new Map()
|
|
249
307
|
|
|
250
308
|
for (const block of blocks) {
|
|
251
|
-
byName.set(block.name, block
|
|
309
|
+
byName.set(block.name, createConfiguredBlock(block.name, {
|
|
310
|
+
...(block.variant ? { variant: block.variant } : {}),
|
|
311
|
+
...(target ? { target } : {}),
|
|
312
|
+
}))
|
|
252
313
|
}
|
|
253
314
|
|
|
254
315
|
return [...byName.values()]
|
|
255
316
|
}
|
|
256
317
|
|
|
318
|
+
function formatConfiguredBlock(block) {
|
|
319
|
+
let value = block.variant ? `${block.name}@${block.variant}` : block.name
|
|
320
|
+
|
|
321
|
+
if (block.target) {
|
|
322
|
+
value = `${value} --target=${block.target}`
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return value
|
|
326
|
+
}
|
|
327
|
+
|
|
257
328
|
async function createMetaValidator(repository) {
|
|
258
329
|
const schema = await repository.getSchema()
|
|
259
330
|
const ajv = new Ajv2020({
|
|
@@ -326,6 +397,8 @@ export function createBlocksService({
|
|
|
326
397
|
assertBlockName(normalizedName)
|
|
327
398
|
|
|
328
399
|
if (!metaCache.has(normalizedName)) {
|
|
400
|
+
validatorPromise ||= createMetaValidator(repository)
|
|
401
|
+
logger.progress?.(`Downloading block metadata: ${normalizedName}`)
|
|
329
402
|
const meta = await repository.getMeta(normalizedName)
|
|
330
403
|
await validateMeta(meta, `block "${normalizedName}"`)
|
|
331
404
|
metaCache.set(normalizedName, meta)
|
|
@@ -335,11 +408,13 @@ export function createBlocksService({
|
|
|
335
408
|
}
|
|
336
409
|
|
|
337
410
|
async function resolveInstallPlan(rootBlocks) {
|
|
338
|
-
const
|
|
411
|
+
const orderedKeys = []
|
|
412
|
+
const orderedKeySet = new Set()
|
|
339
413
|
const resolving = new Set()
|
|
340
|
-
const
|
|
414
|
+
const propagated = new Set()
|
|
415
|
+
const itemsByKey = new Map()
|
|
341
416
|
|
|
342
|
-
async function visit(name, preferredVariant) {
|
|
417
|
+
async function visit(name, preferredVariant, target) {
|
|
343
418
|
const normalizedName = normalizeBlockName(name)
|
|
344
419
|
const meta = await getMeta(normalizedName)
|
|
345
420
|
const variantName = preferredVariant || meta.install.defaultVariant
|
|
@@ -350,36 +425,61 @@ export function createBlocksService({
|
|
|
350
425
|
}
|
|
351
426
|
|
|
352
427
|
const key = toBlockKey({ name: normalizedName, variant: variantName })
|
|
428
|
+
const traversalKey = createTraversalKey(normalizedName, variantName, target)
|
|
353
429
|
|
|
354
|
-
if (
|
|
430
|
+
if (propagated.has(traversalKey)) {
|
|
431
|
+
itemsByKey.get(key)?.targets.add(target)
|
|
355
432
|
return
|
|
356
433
|
}
|
|
357
434
|
|
|
358
|
-
if (resolving.has(
|
|
435
|
+
if (resolving.has(traversalKey)) {
|
|
359
436
|
throw new Error(`Dependency cycle detected at "${key}"`)
|
|
360
437
|
}
|
|
361
438
|
|
|
362
|
-
|
|
439
|
+
let item = itemsByKey.get(key)
|
|
440
|
+
|
|
441
|
+
if (!item) {
|
|
442
|
+
item = {
|
|
443
|
+
name: normalizedName,
|
|
444
|
+
variant: variantName,
|
|
445
|
+
meta,
|
|
446
|
+
installVariant: variant,
|
|
447
|
+
targets: new Set(),
|
|
448
|
+
}
|
|
449
|
+
itemsByKey.set(key, item)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
item.targets.add(target)
|
|
453
|
+
resolving.add(traversalKey)
|
|
363
454
|
|
|
364
455
|
for (const dependency of variant.dependencies?.components ?? []) {
|
|
365
|
-
await visit(dependency.name, dependency.variant || variantName)
|
|
456
|
+
await visit(dependency.name, dependency.variant || variantName, target)
|
|
366
457
|
}
|
|
367
458
|
|
|
368
|
-
resolving.delete(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
})
|
|
459
|
+
resolving.delete(traversalKey)
|
|
460
|
+
propagated.add(traversalKey)
|
|
461
|
+
|
|
462
|
+
if (!orderedKeySet.has(key)) {
|
|
463
|
+
orderedKeySet.add(key)
|
|
464
|
+
orderedKeys.push(key)
|
|
465
|
+
}
|
|
376
466
|
}
|
|
377
467
|
|
|
378
468
|
for (const block of rootBlocks) {
|
|
379
|
-
await visit(block.name, block.variant)
|
|
469
|
+
await visit(block.name, block.variant, block.target)
|
|
380
470
|
}
|
|
381
471
|
|
|
382
|
-
return
|
|
472
|
+
return orderedKeys.map((key) => {
|
|
473
|
+
const item = itemsByKey.get(key)
|
|
474
|
+
const targets = [...item.targets]
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
...item,
|
|
478
|
+
targets,
|
|
479
|
+
selectedFiles: filterRulesByTargets(item.installVariant.files ?? [], targets),
|
|
480
|
+
selectedSharedFiles: filterRulesByTargets(item.meta.install.sharedFiles ?? [], targets),
|
|
481
|
+
}
|
|
482
|
+
})
|
|
383
483
|
}
|
|
384
484
|
|
|
385
485
|
function summarizePlan(plan) {
|
|
@@ -389,7 +489,7 @@ export function createBlocksService({
|
|
|
389
489
|
const warnings = []
|
|
390
490
|
|
|
391
491
|
for (const item of plan) {
|
|
392
|
-
const sharedFiles = item.
|
|
492
|
+
const sharedFiles = item.selectedSharedFiles
|
|
393
493
|
|
|
394
494
|
if (sharedFiles.length > 0) {
|
|
395
495
|
warnings.push(`Block "${item.name}" contains ${sharedFiles.length} sharedFiles entries. raw-only mode does not install them yet, skipping.`)
|
|
@@ -449,6 +549,7 @@ export function createBlocksService({
|
|
|
449
549
|
}
|
|
450
550
|
}
|
|
451
551
|
|
|
552
|
+
logger.progress?.(`Downloading ${relativeTargetPath}`)
|
|
452
553
|
const contents = await repository.getFileBuffer(blockName, rule.source)
|
|
453
554
|
|
|
454
555
|
fse.ensureDirSync(path.dirname(targetPath))
|
|
@@ -468,7 +569,7 @@ export function createBlocksService({
|
|
|
468
569
|
for (const item of plan) {
|
|
469
570
|
logger.info(`install ${item.name}@${item.variant}`)
|
|
470
571
|
|
|
471
|
-
for (const rule of item.
|
|
572
|
+
for (const rule of item.selectedFiles) {
|
|
472
573
|
await installFile(item.name, rule)
|
|
473
574
|
}
|
|
474
575
|
}
|
|
@@ -498,7 +599,7 @@ export function createBlocksService({
|
|
|
498
599
|
for (const item of [...items].reverse()) {
|
|
499
600
|
logger.info(`remove ${item.name}@${item.variant}`)
|
|
500
601
|
|
|
501
|
-
for (const rule of item.
|
|
602
|
+
for (const rule of item.selectedFiles) {
|
|
502
603
|
const targetPath = resolveTargetPath(projectRoot, rule)
|
|
503
604
|
const relativeTargetPath = path.relative(projectRoot, targetPath) || path.basename(targetPath)
|
|
504
605
|
|
|
@@ -526,8 +627,8 @@ export function createBlocksService({
|
|
|
526
627
|
return blocks
|
|
527
628
|
}
|
|
528
629
|
|
|
529
|
-
async function addBlock(rawName) {
|
|
530
|
-
const result = await addBlocks([rawName])
|
|
630
|
+
async function addBlock(rawName, options = {}) {
|
|
631
|
+
const result = await addBlocks([rawName], options)
|
|
531
632
|
|
|
532
633
|
return {
|
|
533
634
|
block: result.blocks[0],
|
|
@@ -536,8 +637,9 @@ export function createBlocksService({
|
|
|
536
637
|
}
|
|
537
638
|
}
|
|
538
639
|
|
|
539
|
-
async function addBlocks(rawNames) {
|
|
540
|
-
const
|
|
640
|
+
async function addBlocks(rawNames, options = {}) {
|
|
641
|
+
const target = normalizeBlockTarget(options.target, 'block target')
|
|
642
|
+
const requestedBlocks = normalizeRequestedBlocks(rawNames, { target })
|
|
541
643
|
const rootBlocks = []
|
|
542
644
|
|
|
543
645
|
for (const requestedBlock of requestedBlocks) {
|
|
@@ -546,7 +648,8 @@ export function createBlocksService({
|
|
|
546
648
|
rootBlocks.push({
|
|
547
649
|
name: requestedBlock.name,
|
|
548
650
|
variant: requestedBlock.variant,
|
|
549
|
-
|
|
651
|
+
target: requestedBlock.target,
|
|
652
|
+
config: { ...requestedBlock.config },
|
|
550
653
|
})
|
|
551
654
|
|
|
552
655
|
if (requestedBlock.variant && !meta.install?.variants?.[requestedBlock.variant]) {
|
|
@@ -559,13 +662,13 @@ export function createBlocksService({
|
|
|
559
662
|
await installPlan(plan)
|
|
560
663
|
|
|
561
664
|
const { configPath, config, blocks } = loadProjectConfig(projectRoot)
|
|
562
|
-
const nextBlocks =
|
|
665
|
+
const nextBlocks = dedupeBlocks([
|
|
563
666
|
...blocks.filter(block => !requestedBlocks.some(requestedBlock => requestedBlock.name === block.name)),
|
|
564
667
|
...rootBlocks,
|
|
565
668
|
])
|
|
566
669
|
|
|
567
670
|
saveProjectConfig(configPath, config, nextBlocks)
|
|
568
|
-
logger.info(`added ${rootBlocks.map(
|
|
671
|
+
logger.info(`added ${rootBlocks.map(formatConfiguredBlock).join(', ')}`)
|
|
569
672
|
|
|
570
673
|
return {
|
|
571
674
|
blocks: rootBlocks,
|
|
@@ -596,8 +699,26 @@ export function createBlocksService({
|
|
|
596
699
|
const beforePlan = await resolveInstallPlan(blocks)
|
|
597
700
|
const remainingBlocks = blocks.filter(block => !names.includes(block.name))
|
|
598
701
|
const afterPlan = remainingBlocks.length > 0 ? await resolveInstallPlan(remainingBlocks) : []
|
|
599
|
-
const
|
|
600
|
-
const removedItems = beforePlan.
|
|
702
|
+
const afterByKey = new Map(afterPlan.map(item => [toBlockKey(item), item]))
|
|
703
|
+
const removedItems = beforePlan.flatMap((item) => {
|
|
704
|
+
const nextItem = afterByKey.get(toBlockKey(item))
|
|
705
|
+
|
|
706
|
+
if (!nextItem) {
|
|
707
|
+
return [item]
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const afterFileKeys = new Set(nextItem.selectedFiles.map(toManagedFileKey))
|
|
711
|
+
const removedFiles = item.selectedFiles.filter(rule => !afterFileKeys.has(toManagedFileKey(rule)))
|
|
712
|
+
|
|
713
|
+
if (removedFiles.length === 0) {
|
|
714
|
+
return []
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return [{
|
|
718
|
+
...item,
|
|
719
|
+
selectedFiles: removedFiles,
|
|
720
|
+
}]
|
|
721
|
+
})
|
|
601
722
|
|
|
602
723
|
await removePlanItems(removedItems)
|
|
603
724
|
saveProjectConfig(configPath, config, remainingBlocks)
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
<section class="x-article-grid-horizontal grid grid-cols-container gap-y-12 md:gap-y-16 py-20 md:py-24 lg:py-28">
|
|
2
|
-
<div class="flex items-end gap-x-10 gap-y-6 w-full justify-between max-md:flex-col">
|
|
3
|
-
<div class="flex flex-col w-full lg:flex-1/2 items-start max-w-161">
|
|
4
|
-
<div class="x-badge mb-1 accent-main-secondary muted">
|
|
5
|
-
Section title
|
|
6
|
-
</div>
|
|
7
|
-
<h2 class="x-heading lg:lg mb-6">
|
|
8
|
-
Attention-grabbing medium length section headline
|
|
9
|
-
</h2>
|
|
10
|
-
<div class="x-text w-full">
|
|
11
|
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
|
12
|
-
Suspendisse varius enim in eros elementum tristique.
|
|
13
|
-
Duis cursus, mi quis viverra ornare, eros dolor interdum nulla.
|
|
14
|
-
</div>
|
|
15
|
-
</div>
|
|
16
|
-
<a href="#" class="x-button lg max-md:mr-auto">
|
|
17
|
-
View all
|
|
18
|
-
<svg class="me-auto size-5" aria-hidden="true">
|
|
19
|
-
<use href="#heroicons-mini/arrow-right"/>
|
|
20
|
-
</svg>
|
|
21
|
-
</a>
|
|
22
|
-
</div>
|
|
23
|
-
<div class="w-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
24
|
-
{foreach range(1,$control->itemsCount ?? 6) as $i}
|
|
25
|
-
{include './ArticleItem.latte', itemVariant => $control->itemVariant, showAuthor => $control->showAuthor, lastHidden => true, mutedBadge => $control->itemVariant !== 'filled'}
|
|
26
|
-
{/foreach}
|
|
27
|
-
</div>
|
|
28
|
-
</section>
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
{default $lastHidden = false}
|
|
2
|
-
{default $itemVariant = 'basic'}
|
|
3
|
-
{default $mutedBadge = false}
|
|
4
|
-
{default $showAuthor = false}
|
|
5
|
-
|
|
6
|
-
<a
|
|
7
|
-
n:class="'x-article-item group flex flex-col', $lastHidden ? 'max-md:last:hidden lg:last:hidden'"
|
|
8
|
-
href="/article/detail.html"
|
|
9
|
-
>
|
|
10
|
-
<picture
|
|
11
|
-
n:class="
|
|
12
|
-
'x-image w-full before:skeleton aspect-310/207 md:aspect-310/207 lg:aspect-418/279 overflow-hidden',
|
|
13
|
-
$itemVariant === 'basic' ? 'rounded-2xl',
|
|
14
|
-
$itemVariant === 'outlined' ? 'rounded-t-2xl border-x border-t border-body-secondary',
|
|
15
|
-
$itemVariant === 'filled' ? 'rounded-t-2xl',
|
|
16
|
-
"
|
|
17
|
-
>
|
|
18
|
-
<source srcset="{=placeholder(345,431)}" media="(width < {$media->sm})">
|
|
19
|
-
<img
|
|
20
|
-
class="size-full object-cover group-hocus:scale-105 transition-transform"
|
|
21
|
-
src="{=placeholder(532,655)}"
|
|
22
|
-
alt=" " width="532" height="655" loading="lazy"
|
|
23
|
-
>
|
|
24
|
-
</picture>
|
|
25
|
-
<div
|
|
26
|
-
n:class="
|
|
27
|
-
'flex flex-col items-start pt-6 grow',
|
|
28
|
-
$itemVariant === 'outlined' ? 'rounded-b-2xl border-x border-b border-body-secondary px-6 pb-4',
|
|
29
|
-
$itemVariant === 'filled' ? 'bg-primary/5 px-6 pb-4 rounded-b-2xl',
|
|
30
|
-
"
|
|
31
|
-
>
|
|
32
|
-
<div class="flex gap-2 flex-wrap mb-3">
|
|
33
|
-
<div n:class="'x-badge sm', $mutedBadge ? 'muted' : ''">Article category</div>
|
|
34
|
-
<div class="x-badge sm muted bg-transparent" n:if="!$showAuthor">5 min read</div>
|
|
35
|
-
</div>
|
|
36
|
-
<div class="x-heading xs mb-4 tracking-[-0.5px]">
|
|
37
|
-
Article title goes here
|
|
38
|
-
</div>
|
|
39
|
-
<div class="x-text text-main-secondary line-clamp-3 mb-4">
|
|
40
|
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat.
|
|
41
|
-
</div>
|
|
42
|
-
<div class="grid grid-cols-[auto_1fr] mb-4 gap-x-4" n:if="$showAuthor">
|
|
43
|
-
<picture class="x-image before:skeleton size-12 rounded-full aspect-square shrink-0 row-span-2 overflow-hidden">
|
|
44
|
-
<img class="object-cover" src="{=placeholder(48,48)}" alt=" " width="48" height="48" loading="lazy">
|
|
45
|
-
</picture>
|
|
46
|
-
<span class="x-text sm font-medium self-end">Author Name</span>
|
|
47
|
-
<div class="flex gap-4">
|
|
48
|
-
<span class="x-text xs text-main-tertiary font-medium">04.12.2025</span>
|
|
49
|
-
<span class="x-text xs text-main-tertiary font-medium">5 min read</span>
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
52
|
-
<span class="x-button ghosted pl-1 mt-auto">
|
|
53
|
-
Read more
|
|
54
|
-
<svg class="me-auto size-5 group-hocus:translate-x-1 transition-transform" aria-hidden="true">
|
|
55
|
-
<use href="#heroicons-mini/arrow-right"/>
|
|
56
|
-
</svg>
|
|
57
|
-
</span>
|
|
58
|
-
</div>
|
|
59
|
-
</a>
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
<header class="x-header-nav-right h-(--x-header-height) grid grid-cols-container bg-body-primary sticky top-0 z-40">
|
|
2
|
-
<div class="flex items-center gap-1.5 md:gap-3">
|
|
3
|
-
<a href="#" class="shrink-0 max-lg:mr-auto lg:mr-7">
|
|
4
|
-
<img class="w-25 h-8" src="{placeholder(100,32)}" loading="lazy" alt="Logo">
|
|
5
|
-
</a>
|
|
6
|
-
<nav class="flex items-center flex-wrap justify-start gap-x-1 max-lg:hidden mr-auto">
|
|
7
|
-
<div class="x-popover trigger-hover group" n:foreach="range(1,4) as $item">
|
|
8
|
-
<a href="#" class="x-text x-link sm font-medium py-1 px-3 flex items-center gap-1.5">
|
|
9
|
-
Menu item {$iterator->counter}
|
|
10
|
-
<svg class="size-4 transition group-hocus:-scale-y-100" n:if="$iterator->last">
|
|
11
|
-
<use href="#heroicons-solid/chevron-down" />
|
|
12
|
-
</svg>
|
|
13
|
-
</a>
|
|
14
|
-
<div
|
|
15
|
-
n:class="
|
|
16
|
-
'x-nav-popover x-popover-content bottom-end border border-body-secondary shadow-lg min-w-55',
|
|
17
|
-
'm-1.5 p-4 flex flex-col gap-1 before:-top-2.5 before:h-2.5'
|
|
18
|
-
"
|
|
19
|
-
n:if="$iterator->last"
|
|
20
|
-
>
|
|
21
|
-
<a href="#" class="x-text x-link sm font-medium py-1 flex items-center gap-1.5" n:foreach="range(1,4) as $item">
|
|
22
|
-
Menu item
|
|
23
|
-
</a>
|
|
24
|
-
</div>
|
|
25
|
-
</div>
|
|
26
|
-
</nav>
|
|
27
|
-
<div class="x-popover trigger-focus group">
|
|
28
|
-
<button class="x-text x-link sm font-medium py-1 px-3 flex items-center gap-1.5" type="button" tabindex="0">
|
|
29
|
-
<img
|
|
30
|
-
class="aspect-4/3 size-4"
|
|
31
|
-
src="https://cdn.jsdelivr.net/npm/flag-icons@7.5.0/flags/1x1/gb.svg"
|
|
32
|
-
alt="at"
|
|
33
|
-
loading="lazy"
|
|
34
|
-
>
|
|
35
|
-
EN
|
|
36
|
-
<svg class="size-4 transition group-focus-within:-scale-y-100">
|
|
37
|
-
<use href="#heroicons-solid/chevron-down" />
|
|
38
|
-
</svg>
|
|
39
|
-
</button>
|
|
40
|
-
<div class="x-popover-content bottom-end border border-body-secondary shadow-lg m-1.5 p-4 flex flex-col gap-1">
|
|
41
|
-
<a href="#" class="x-text x-link sm font-medium py-1 flex items-center gap-1.5" n:foreach="range(1,4) as $item">
|
|
42
|
-
<img
|
|
43
|
-
class="aspect-4/3 size-4"
|
|
44
|
-
src="https://cdn.jsdelivr.net/npm/flag-icons@7.5.0/flags/1x1/gb.svg"
|
|
45
|
-
alt="at"
|
|
46
|
-
loading="lazy"
|
|
47
|
-
>
|
|
48
|
-
English
|
|
49
|
-
</a>
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
52
|
-
<a href="#" class="x-button sm max-md:hidden">
|
|
53
|
-
Call to action
|
|
54
|
-
</a>
|
|
55
|
-
<button
|
|
56
|
-
class="x-button square sm lg:hidden group swap"
|
|
57
|
-
type="button"
|
|
58
|
-
data-invoke-action="x-drawer#toggle"
|
|
59
|
-
data-invoke-target="#navDrawer"
|
|
60
|
-
aria-controls="navDrawer"
|
|
61
|
-
aria-expanded="false"
|
|
62
|
-
>
|
|
63
|
-
<svg class="size-5 shrink-0 not-group-aria-expanded:opacity-100 group-aria-expanded:rotate-45" aria-hidden="true">
|
|
64
|
-
<use href="#heroicons-solid/bars-3"></use>
|
|
65
|
-
</svg>
|
|
66
|
-
<svg class="size-5 shrink-0 not-group-aria-expanded:-rotate-45 group-aria-expanded:opacity-100" aria-hidden="true">
|
|
67
|
-
<use href="#heroicons-solid/x-mark"></use>
|
|
68
|
-
</svg>
|
|
69
|
-
</button>
|
|
70
|
-
</div>
|
|
71
|
-
</header>
|