@newlogic-digital/cli 1.5.0-next.5 → 1.5.0-next.6
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 -52
- 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 +6 -3
- package/src/commands/blocks/service.mjs +179 -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,51 +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
73
|
|
|
122
74
|
const rawArgs = process.argv.slice(2)
|
|
123
75
|
const command = rawArgs[0]
|
|
@@ -150,12 +102,13 @@ if (!command) {
|
|
|
150
102
|
|
|
151
103
|
-- blocks --
|
|
152
104
|
${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
|
|
105
|
+
${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
106
|
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies
|
|
155
107
|
${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}
|
|
156
108
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'about-accordion')}
|
|
157
109
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'AboutAccordion')}
|
|
158
110
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left@stimulus')}
|
|
111
|
+
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card')} ${styleText('cyan', '--target=ui')}
|
|
159
112
|
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}
|
|
160
113
|
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'about-accordion')}
|
|
161
114
|
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}
|
|
@@ -185,11 +138,11 @@ if (command === 'cms') {
|
|
|
185
138
|
}
|
|
186
139
|
|
|
187
140
|
if (command === 'blocks') {
|
|
188
|
-
const { positionals } = parseCommandArgs(rawArgs.slice(1))
|
|
141
|
+
const { positionals, options } = parseCommandArgs(rawArgs.slice(1))
|
|
189
142
|
const action = positionals[0]
|
|
190
143
|
const names = positionals.slice(1)
|
|
191
144
|
|
|
192
|
-
await blocks(action, names)
|
|
145
|
+
await blocks(action, names, options)
|
|
193
146
|
}
|
|
194
147
|
|
|
195
148
|
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
|
+
}
|
|
@@ -134,7 +134,7 @@ function printBlocksUsage() {
|
|
|
134
134
|
styleText(['white', 'bold'], 'Usage:'),
|
|
135
135
|
'',
|
|
136
136
|
` ${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`,
|
|
137
|
+
` ${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
138
|
` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies`,
|
|
139
139
|
` ${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}`,
|
|
140
140
|
'',
|
|
@@ -142,11 +142,12 @@ function printBlocksUsage() {
|
|
|
142
142
|
'',
|
|
143
143
|
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}`,
|
|
144
144
|
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left@stimulus')}`,
|
|
145
|
+
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card')} ${styleText('cyan', '--target=ui')}`,
|
|
145
146
|
` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}`,
|
|
146
147
|
].join('\n'))
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
export default async function blocks(action, names = []) {
|
|
150
|
+
export default async function blocks(action, names = [], options = {}) {
|
|
150
151
|
if (!action || action === 'help' || action === '--help') {
|
|
151
152
|
printBlocksUsage()
|
|
152
153
|
return
|
|
@@ -167,7 +168,9 @@ export default async function blocks(action, names = []) {
|
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
const service = createBlocksService({ logger: createCliLogger() })
|
|
170
|
-
await service.addBlocks(names
|
|
171
|
+
await service.addBlocks(names, {
|
|
172
|
+
target: options.target,
|
|
173
|
+
})
|
|
171
174
|
return
|
|
172
175
|
}
|
|
173
176
|
|
|
@@ -8,6 +8,7 @@ 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 {
|
|
@@ -83,27 +84,27 @@ function assertBlockName(name, label = 'block name') {
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
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()
|
|
87
|
+
function normalizeBlockTarget(rawValue, label = 'block target') {
|
|
88
|
+
if (rawValue == null) {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
93
91
|
|
|
94
|
-
|
|
92
|
+
const target = `${rawValue}`.trim().toLowerCase()
|
|
95
93
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
if (!target) {
|
|
95
|
+
throw new Error(`Invalid ${label}: "${rawValue}"`)
|
|
96
|
+
}
|
|
99
97
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
variant: variant || undefined,
|
|
103
|
-
variantExplicit: rawValue.variant != null,
|
|
104
|
-
}
|
|
98
|
+
if (!BLOCK_TARGETS.has(target)) {
|
|
99
|
+
throw new Error(`Invalid ${label}: "${target}"`)
|
|
105
100
|
}
|
|
106
101
|
|
|
102
|
+
return target
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseBlockSpecifier(rawValue, {
|
|
106
|
+
label = 'block name',
|
|
107
|
+
} = {}) {
|
|
107
108
|
const value = `${rawValue ?? ''}`.trim()
|
|
108
109
|
|
|
109
110
|
if (!value) {
|
|
@@ -124,7 +125,6 @@ function parseBlockSpecifier(rawValue, {
|
|
|
124
125
|
return {
|
|
125
126
|
name,
|
|
126
127
|
variant: rawVariant || undefined,
|
|
127
|
-
variantExplicit: variantSeparator !== -1,
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -132,6 +132,54 @@ function toBlockKey(block) {
|
|
|
132
132
|
return `${block.name}@${block.variant}`
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function createTraversalKey(name, variant, target) {
|
|
136
|
+
return `${name}@${variant}#${target || '*'}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createConfiguredBlock(name, config = {}) {
|
|
140
|
+
const nextConfig = { ...config }
|
|
141
|
+
|
|
142
|
+
if (nextConfig.variant != null) {
|
|
143
|
+
const variant = `${nextConfig.variant}`.trim()
|
|
144
|
+
|
|
145
|
+
if (!variant) {
|
|
146
|
+
throw new Error(`Configured block "${name}" has an empty variant`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
nextConfig.variant = variant
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (nextConfig.target != null) {
|
|
153
|
+
nextConfig.target = normalizeBlockTarget(nextConfig.target, `configured block "${name}" target`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
name,
|
|
158
|
+
variant: nextConfig.variant,
|
|
159
|
+
target: nextConfig.target,
|
|
160
|
+
config: nextConfig,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function filterRulesByTargets(rules = [], targets = []) {
|
|
165
|
+
const targetSet = new Set(targets)
|
|
166
|
+
|
|
167
|
+
if (targetSet.has(undefined)) {
|
|
168
|
+
return rules
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return rules.filter(rule => rule.target && targetSet.has(rule.target))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toManagedFileKey(rule) {
|
|
175
|
+
return [
|
|
176
|
+
rule.source,
|
|
177
|
+
rule.destination,
|
|
178
|
+
rule.parentDirectory || '',
|
|
179
|
+
rule.fileName,
|
|
180
|
+
].join('\0')
|
|
181
|
+
}
|
|
182
|
+
|
|
135
183
|
function resolveTargetPath(projectRoot, rule) {
|
|
136
184
|
const destinationRoot = resolveInside(projectRoot, rule.destination)
|
|
137
185
|
const parentDirectory = rule.parentDirectory ? rule.parentDirectory.split('/').filter(Boolean) : []
|
|
@@ -171,7 +219,7 @@ function loadProjectConfig(projectRoot) {
|
|
|
171
219
|
if (!fs.existsSync(configPath)) {
|
|
172
220
|
return {
|
|
173
221
|
configPath,
|
|
174
|
-
config: { blocks:
|
|
222
|
+
config: { blocks: {} },
|
|
175
223
|
blocks: [],
|
|
176
224
|
}
|
|
177
225
|
}
|
|
@@ -189,36 +237,42 @@ function loadProjectConfig(projectRoot) {
|
|
|
189
237
|
throw new Error(`${CONFIG_FILE_NAME} must contain a JSON object`)
|
|
190
238
|
}
|
|
191
239
|
|
|
192
|
-
const blocksRaw = config.blocks ??
|
|
240
|
+
const blocksRaw = config.blocks ?? {}
|
|
193
241
|
|
|
194
|
-
if (!Array.isArray(blocksRaw)) {
|
|
195
|
-
throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an
|
|
242
|
+
if (!blocksRaw || typeof blocksRaw !== 'object' || Array.isArray(blocksRaw)) {
|
|
243
|
+
throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an object`)
|
|
196
244
|
}
|
|
197
245
|
|
|
198
|
-
const blocks = blocksRaw.map((
|
|
199
|
-
|
|
200
|
-
|
|
246
|
+
const blocks = Object.entries(blocksRaw).map(([rawName, rawConfig]) => {
|
|
247
|
+
const name = normalizeBlockName(rawName)
|
|
248
|
+
|
|
249
|
+
assertBlockName(name, 'configured block name')
|
|
250
|
+
|
|
251
|
+
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
|
|
252
|
+
throw new Error(`Configured block "${name}" must use an object value`)
|
|
201
253
|
}
|
|
202
254
|
|
|
203
|
-
return
|
|
204
|
-
label: `block config entry at index ${index}`,
|
|
205
|
-
allowObject: true,
|
|
206
|
-
})
|
|
255
|
+
return createConfiguredBlock(name, rawConfig)
|
|
207
256
|
})
|
|
208
257
|
|
|
209
|
-
return { configPath, config, blocks
|
|
258
|
+
return { configPath, config, blocks }
|
|
210
259
|
}
|
|
211
260
|
|
|
212
261
|
function saveProjectConfig(configPath, config, blocks) {
|
|
213
262
|
const nextConfig = {
|
|
214
263
|
...config,
|
|
215
|
-
blocks:
|
|
264
|
+
blocks: Object.fromEntries(
|
|
265
|
+
blocks.map(block => [
|
|
266
|
+
block.name,
|
|
267
|
+
{ ...(block.config ?? {}) },
|
|
268
|
+
]),
|
|
269
|
+
),
|
|
216
270
|
}
|
|
217
271
|
|
|
218
272
|
fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`)
|
|
219
273
|
}
|
|
220
274
|
|
|
221
|
-
function
|
|
275
|
+
function dedupeBlocks(blocks) {
|
|
222
276
|
const byName = new Map()
|
|
223
277
|
|
|
224
278
|
for (const block of blocks) {
|
|
@@ -239,7 +293,10 @@ function normalizeRequestedBlockNames(rawNames, label = 'block name') {
|
|
|
239
293
|
return [...new Set(normalizedNames)]
|
|
240
294
|
}
|
|
241
295
|
|
|
242
|
-
function normalizeRequestedBlocks(rawNames,
|
|
296
|
+
function normalizeRequestedBlocks(rawNames, {
|
|
297
|
+
label = 'block name',
|
|
298
|
+
target,
|
|
299
|
+
} = {}) {
|
|
243
300
|
if (!Array.isArray(rawNames) || rawNames.length === 0) {
|
|
244
301
|
throw new Error(`Missing ${label}`)
|
|
245
302
|
}
|
|
@@ -248,12 +305,25 @@ function normalizeRequestedBlocks(rawNames, label = 'block name') {
|
|
|
248
305
|
const byName = new Map()
|
|
249
306
|
|
|
250
307
|
for (const block of blocks) {
|
|
251
|
-
byName.set(block.name, block
|
|
308
|
+
byName.set(block.name, createConfiguredBlock(block.name, {
|
|
309
|
+
...(block.variant ? { variant: block.variant } : {}),
|
|
310
|
+
...(target ? { target } : {}),
|
|
311
|
+
}))
|
|
252
312
|
}
|
|
253
313
|
|
|
254
314
|
return [...byName.values()]
|
|
255
315
|
}
|
|
256
316
|
|
|
317
|
+
function formatConfiguredBlock(block) {
|
|
318
|
+
let value = block.variant ? `${block.name}@${block.variant}` : block.name
|
|
319
|
+
|
|
320
|
+
if (block.target) {
|
|
321
|
+
value = `${value} --target=${block.target}`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return value
|
|
325
|
+
}
|
|
326
|
+
|
|
257
327
|
async function createMetaValidator(repository) {
|
|
258
328
|
const schema = await repository.getSchema()
|
|
259
329
|
const ajv = new Ajv2020({
|
|
@@ -335,11 +405,13 @@ export function createBlocksService({
|
|
|
335
405
|
}
|
|
336
406
|
|
|
337
407
|
async function resolveInstallPlan(rootBlocks) {
|
|
338
|
-
const
|
|
408
|
+
const orderedKeys = []
|
|
409
|
+
const orderedKeySet = new Set()
|
|
339
410
|
const resolving = new Set()
|
|
340
|
-
const
|
|
411
|
+
const propagated = new Set()
|
|
412
|
+
const itemsByKey = new Map()
|
|
341
413
|
|
|
342
|
-
async function visit(name, preferredVariant) {
|
|
414
|
+
async function visit(name, preferredVariant, target) {
|
|
343
415
|
const normalizedName = normalizeBlockName(name)
|
|
344
416
|
const meta = await getMeta(normalizedName)
|
|
345
417
|
const variantName = preferredVariant || meta.install.defaultVariant
|
|
@@ -350,36 +422,61 @@ export function createBlocksService({
|
|
|
350
422
|
}
|
|
351
423
|
|
|
352
424
|
const key = toBlockKey({ name: normalizedName, variant: variantName })
|
|
425
|
+
const traversalKey = createTraversalKey(normalizedName, variantName, target)
|
|
353
426
|
|
|
354
|
-
if (
|
|
427
|
+
if (propagated.has(traversalKey)) {
|
|
428
|
+
itemsByKey.get(key)?.targets.add(target)
|
|
355
429
|
return
|
|
356
430
|
}
|
|
357
431
|
|
|
358
|
-
if (resolving.has(
|
|
432
|
+
if (resolving.has(traversalKey)) {
|
|
359
433
|
throw new Error(`Dependency cycle detected at "${key}"`)
|
|
360
434
|
}
|
|
361
435
|
|
|
362
|
-
|
|
436
|
+
let item = itemsByKey.get(key)
|
|
437
|
+
|
|
438
|
+
if (!item) {
|
|
439
|
+
item = {
|
|
440
|
+
name: normalizedName,
|
|
441
|
+
variant: variantName,
|
|
442
|
+
meta,
|
|
443
|
+
installVariant: variant,
|
|
444
|
+
targets: new Set(),
|
|
445
|
+
}
|
|
446
|
+
itemsByKey.set(key, item)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
item.targets.add(target)
|
|
450
|
+
resolving.add(traversalKey)
|
|
363
451
|
|
|
364
452
|
for (const dependency of variant.dependencies?.components ?? []) {
|
|
365
|
-
await visit(dependency.name, dependency.variant || variantName)
|
|
453
|
+
await visit(dependency.name, dependency.variant || variantName, target)
|
|
366
454
|
}
|
|
367
455
|
|
|
368
|
-
resolving.delete(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
})
|
|
456
|
+
resolving.delete(traversalKey)
|
|
457
|
+
propagated.add(traversalKey)
|
|
458
|
+
|
|
459
|
+
if (!orderedKeySet.has(key)) {
|
|
460
|
+
orderedKeySet.add(key)
|
|
461
|
+
orderedKeys.push(key)
|
|
462
|
+
}
|
|
376
463
|
}
|
|
377
464
|
|
|
378
465
|
for (const block of rootBlocks) {
|
|
379
|
-
await visit(block.name, block.variant)
|
|
466
|
+
await visit(block.name, block.variant, block.target)
|
|
380
467
|
}
|
|
381
468
|
|
|
382
|
-
return
|
|
469
|
+
return orderedKeys.map((key) => {
|
|
470
|
+
const item = itemsByKey.get(key)
|
|
471
|
+
const targets = [...item.targets]
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
...item,
|
|
475
|
+
targets,
|
|
476
|
+
selectedFiles: filterRulesByTargets(item.installVariant.files ?? [], targets),
|
|
477
|
+
selectedSharedFiles: filterRulesByTargets(item.meta.install.sharedFiles ?? [], targets),
|
|
478
|
+
}
|
|
479
|
+
})
|
|
383
480
|
}
|
|
384
481
|
|
|
385
482
|
function summarizePlan(plan) {
|
|
@@ -389,7 +486,7 @@ export function createBlocksService({
|
|
|
389
486
|
const warnings = []
|
|
390
487
|
|
|
391
488
|
for (const item of plan) {
|
|
392
|
-
const sharedFiles = item.
|
|
489
|
+
const sharedFiles = item.selectedSharedFiles
|
|
393
490
|
|
|
394
491
|
if (sharedFiles.length > 0) {
|
|
395
492
|
warnings.push(`Block "${item.name}" contains ${sharedFiles.length} sharedFiles entries. raw-only mode does not install them yet, skipping.`)
|
|
@@ -468,7 +565,7 @@ export function createBlocksService({
|
|
|
468
565
|
for (const item of plan) {
|
|
469
566
|
logger.info(`install ${item.name}@${item.variant}`)
|
|
470
567
|
|
|
471
|
-
for (const rule of item.
|
|
568
|
+
for (const rule of item.selectedFiles) {
|
|
472
569
|
await installFile(item.name, rule)
|
|
473
570
|
}
|
|
474
571
|
}
|
|
@@ -498,7 +595,7 @@ export function createBlocksService({
|
|
|
498
595
|
for (const item of [...items].reverse()) {
|
|
499
596
|
logger.info(`remove ${item.name}@${item.variant}`)
|
|
500
597
|
|
|
501
|
-
for (const rule of item.
|
|
598
|
+
for (const rule of item.selectedFiles) {
|
|
502
599
|
const targetPath = resolveTargetPath(projectRoot, rule)
|
|
503
600
|
const relativeTargetPath = path.relative(projectRoot, targetPath) || path.basename(targetPath)
|
|
504
601
|
|
|
@@ -526,8 +623,8 @@ export function createBlocksService({
|
|
|
526
623
|
return blocks
|
|
527
624
|
}
|
|
528
625
|
|
|
529
|
-
async function addBlock(rawName) {
|
|
530
|
-
const result = await addBlocks([rawName])
|
|
626
|
+
async function addBlock(rawName, options = {}) {
|
|
627
|
+
const result = await addBlocks([rawName], options)
|
|
531
628
|
|
|
532
629
|
return {
|
|
533
630
|
block: result.blocks[0],
|
|
@@ -536,8 +633,9 @@ export function createBlocksService({
|
|
|
536
633
|
}
|
|
537
634
|
}
|
|
538
635
|
|
|
539
|
-
async function addBlocks(rawNames) {
|
|
540
|
-
const
|
|
636
|
+
async function addBlocks(rawNames, options = {}) {
|
|
637
|
+
const target = normalizeBlockTarget(options.target, 'block target')
|
|
638
|
+
const requestedBlocks = normalizeRequestedBlocks(rawNames, { target })
|
|
541
639
|
const rootBlocks = []
|
|
542
640
|
|
|
543
641
|
for (const requestedBlock of requestedBlocks) {
|
|
@@ -546,7 +644,8 @@ export function createBlocksService({
|
|
|
546
644
|
rootBlocks.push({
|
|
547
645
|
name: requestedBlock.name,
|
|
548
646
|
variant: requestedBlock.variant,
|
|
549
|
-
|
|
647
|
+
target: requestedBlock.target,
|
|
648
|
+
config: { ...requestedBlock.config },
|
|
550
649
|
})
|
|
551
650
|
|
|
552
651
|
if (requestedBlock.variant && !meta.install?.variants?.[requestedBlock.variant]) {
|
|
@@ -559,13 +658,13 @@ export function createBlocksService({
|
|
|
559
658
|
await installPlan(plan)
|
|
560
659
|
|
|
561
660
|
const { configPath, config, blocks } = loadProjectConfig(projectRoot)
|
|
562
|
-
const nextBlocks =
|
|
661
|
+
const nextBlocks = dedupeBlocks([
|
|
563
662
|
...blocks.filter(block => !requestedBlocks.some(requestedBlock => requestedBlock.name === block.name)),
|
|
564
663
|
...rootBlocks,
|
|
565
664
|
])
|
|
566
665
|
|
|
567
666
|
saveProjectConfig(configPath, config, nextBlocks)
|
|
568
|
-
logger.info(`added ${rootBlocks.map(
|
|
667
|
+
logger.info(`added ${rootBlocks.map(formatConfiguredBlock).join(', ')}`)
|
|
569
668
|
|
|
570
669
|
return {
|
|
571
670
|
blocks: rootBlocks,
|
|
@@ -596,8 +695,26 @@ export function createBlocksService({
|
|
|
596
695
|
const beforePlan = await resolveInstallPlan(blocks)
|
|
597
696
|
const remainingBlocks = blocks.filter(block => !names.includes(block.name))
|
|
598
697
|
const afterPlan = remainingBlocks.length > 0 ? await resolveInstallPlan(remainingBlocks) : []
|
|
599
|
-
const
|
|
600
|
-
const removedItems = beforePlan.
|
|
698
|
+
const afterByKey = new Map(afterPlan.map(item => [toBlockKey(item), item]))
|
|
699
|
+
const removedItems = beforePlan.flatMap((item) => {
|
|
700
|
+
const nextItem = afterByKey.get(toBlockKey(item))
|
|
701
|
+
|
|
702
|
+
if (!nextItem) {
|
|
703
|
+
return [item]
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const afterFileKeys = new Set(nextItem.selectedFiles.map(toManagedFileKey))
|
|
707
|
+
const removedFiles = item.selectedFiles.filter(rule => !afterFileKeys.has(toManagedFileKey(rule)))
|
|
708
|
+
|
|
709
|
+
if (removedFiles.length === 0) {
|
|
710
|
+
return []
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return [{
|
|
714
|
+
...item,
|
|
715
|
+
selectedFiles: removedFiles,
|
|
716
|
+
}]
|
|
717
|
+
})
|
|
601
718
|
|
|
602
719
|
await removePlanItems(removedItems)
|
|
603
720
|
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>
|