@newlogic-digital/cli 1.5.0-next.4 → 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 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,11 +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...>')} - 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')}
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')}
158
112
  ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}
159
113
  ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'about-accordion')}
160
114
  ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}
@@ -184,11 +138,11 @@ if (command === 'cms') {
184
138
  }
185
139
 
186
140
  if (command === 'blocks') {
187
- const { positionals } = parseCommandArgs(rawArgs.slice(1))
141
+ const { positionals, options } = parseCommandArgs(rawArgs.slice(1))
188
142
  const action = positionals[0]
189
143
  const names = positionals.slice(1)
190
144
 
191
- await blocks(action, names)
145
+ await blocks(action, names, options)
192
146
  }
193
147
 
194
148
  if (command === 'skills') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newlogic-digital/cli",
3
- "version": "1.5.0-next.4",
3
+ "version": "1.5.0-next.6",
4
4
  "main": "index.mjs",
5
5
  "bin": {
6
6
  "newlogic-cli": "index.mjs",
@@ -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
- - Installed blocks are recorded in `newlogic.config.json`.
72
- - `update` reinstalls all configured blocks from `newlogic.config.json`.
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
 
@@ -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,18 +134,20 @@ 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...>')} - 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
  '',
141
141
  styleText(['white', 'bold'], 'Examples:'),
142
142
  '',
143
143
  ` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}`,
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')}`,
144
146
  ` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}`,
145
147
  ].join('\n'))
146
148
  }
147
149
 
148
- export default async function blocks(action, names = []) {
150
+ export default async function blocks(action, names = [], options = {}) {
149
151
  if (!action || action === 'help' || action === '--help') {
150
152
  printBlocksUsage()
151
153
  return
@@ -166,7 +168,9 @@ export default async function blocks(action, names = []) {
166
168
  }
167
169
 
168
170
  const service = createBlocksService({ logger: createCliLogger() })
169
- await service.addBlocks(names)
171
+ await service.addBlocks(names, {
172
+ target: options.target,
173
+ })
170
174
  return
171
175
  }
172
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,10 +84,102 @@ function assertBlockName(name, label = 'block name') {
83
84
  }
84
85
  }
85
86
 
87
+ function normalizeBlockTarget(rawValue, label = 'block target') {
88
+ if (rawValue == null) {
89
+ return undefined
90
+ }
91
+
92
+ const target = `${rawValue}`.trim().toLowerCase()
93
+
94
+ if (!target) {
95
+ throw new Error(`Invalid ${label}: "${rawValue}"`)
96
+ }
97
+
98
+ if (!BLOCK_TARGETS.has(target)) {
99
+ throw new Error(`Invalid ${label}: "${target}"`)
100
+ }
101
+
102
+ return target
103
+ }
104
+
105
+ function parseBlockSpecifier(rawValue, {
106
+ label = 'block name',
107
+ } = {}) {
108
+ const value = `${rawValue ?? ''}`.trim()
109
+
110
+ if (!value) {
111
+ throw new Error(`Missing ${label}`)
112
+ }
113
+
114
+ const variantSeparator = value.indexOf('@')
115
+ const rawName = variantSeparator === -1 ? value : value.slice(0, variantSeparator)
116
+ const rawVariant = variantSeparator === -1 ? '' : value.slice(variantSeparator + 1).trim()
117
+ const name = normalizeBlockName(rawName)
118
+
119
+ assertBlockName(name, label)
120
+
121
+ if (variantSeparator !== -1 && !rawVariant) {
122
+ throw new Error(`Invalid ${label}: "${value}"`)
123
+ }
124
+
125
+ return {
126
+ name,
127
+ variant: rawVariant || undefined,
128
+ }
129
+ }
130
+
86
131
  function toBlockKey(block) {
87
132
  return `${block.name}@${block.variant}`
88
133
  }
89
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
+
90
183
  function resolveTargetPath(projectRoot, rule) {
91
184
  const destinationRoot = resolveInside(projectRoot, rule.destination)
92
185
  const parentDirectory = rule.parentDirectory ? rule.parentDirectory.split('/').filter(Boolean) : []
@@ -126,7 +219,7 @@ function loadProjectConfig(projectRoot) {
126
219
  if (!fs.existsSync(configPath)) {
127
220
  return {
128
221
  configPath,
129
- config: { blocks: [] },
222
+ config: { blocks: {} },
130
223
  blocks: [],
131
224
  }
132
225
  }
@@ -144,42 +237,42 @@ function loadProjectConfig(projectRoot) {
144
237
  throw new Error(`${CONFIG_FILE_NAME} must contain a JSON object`)
145
238
  }
146
239
 
147
- const blocksRaw = config.blocks ?? []
240
+ const blocksRaw = config.blocks ?? {}
148
241
 
149
- if (!Array.isArray(blocksRaw)) {
150
- throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an array`)
242
+ if (!blocksRaw || typeof blocksRaw !== 'object' || Array.isArray(blocksRaw)) {
243
+ throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an object`)
151
244
  }
152
245
 
153
- const blocks = blocksRaw.map((entry, index) => {
154
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
155
- throw new Error(`Invalid block config entry at index ${index}`)
156
- }
157
-
158
- const name = normalizeBlockName(entry.name)
159
- const variant = `${entry.variant ?? ''}`.trim()
246
+ const blocks = Object.entries(blocksRaw).map(([rawName, rawConfig]) => {
247
+ const name = normalizeBlockName(rawName)
160
248
 
161
249
  assertBlockName(name, 'configured block name')
162
250
 
163
- if (!variant) {
164
- throw new Error(`Configured block "${name}" is missing a variant`)
251
+ if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
252
+ throw new Error(`Configured block "${name}" must use an object value`)
165
253
  }
166
254
 
167
- return { name, variant }
255
+ return createConfiguredBlock(name, rawConfig)
168
256
  })
169
257
 
170
- return { configPath, config, blocks: dedupeExplicitBlocks(blocks) }
258
+ return { configPath, config, blocks }
171
259
  }
172
260
 
173
261
  function saveProjectConfig(configPath, config, blocks) {
174
262
  const nextConfig = {
175
263
  ...config,
176
- blocks,
264
+ blocks: Object.fromEntries(
265
+ blocks.map(block => [
266
+ block.name,
267
+ { ...(block.config ?? {}) },
268
+ ]),
269
+ ),
177
270
  }
178
271
 
179
272
  fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`)
180
273
  }
181
274
 
182
- function dedupeExplicitBlocks(blocks) {
275
+ function dedupeBlocks(blocks) {
183
276
  const byName = new Map()
184
277
 
185
278
  for (const block of blocks) {
@@ -194,15 +287,41 @@ function normalizeRequestedBlockNames(rawNames, label = 'block name') {
194
287
  throw new Error(`Missing ${label}`)
195
288
  }
196
289
 
197
- const normalizedNames = rawNames.map((rawName) => {
198
- const name = normalizeBlockName(rawName)
290
+ const normalizedNames = rawNames
291
+ .map(rawName => parseBlockSpecifier(rawName, { label }).name)
199
292
 
200
- assertBlockName(name, label)
293
+ return [...new Set(normalizedNames)]
294
+ }
201
295
 
202
- return name
203
- })
296
+ function normalizeRequestedBlocks(rawNames, {
297
+ label = 'block name',
298
+ target,
299
+ } = {}) {
300
+ if (!Array.isArray(rawNames) || rawNames.length === 0) {
301
+ throw new Error(`Missing ${label}`)
302
+ }
204
303
 
205
- return [...new Set(normalizedNames)]
304
+ const blocks = rawNames.map(rawName => parseBlockSpecifier(rawName, { label }))
305
+ const byName = new Map()
306
+
307
+ for (const block of blocks) {
308
+ byName.set(block.name, createConfiguredBlock(block.name, {
309
+ ...(block.variant ? { variant: block.variant } : {}),
310
+ ...(target ? { target } : {}),
311
+ }))
312
+ }
313
+
314
+ return [...byName.values()]
315
+ }
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
206
325
  }
207
326
 
208
327
  async function createMetaValidator(repository) {
@@ -286,11 +405,13 @@ export function createBlocksService({
286
405
  }
287
406
 
288
407
  async function resolveInstallPlan(rootBlocks) {
289
- const visited = new Set()
408
+ const orderedKeys = []
409
+ const orderedKeySet = new Set()
290
410
  const resolving = new Set()
291
- const plan = []
411
+ const propagated = new Set()
412
+ const itemsByKey = new Map()
292
413
 
293
- async function visit(name, preferredVariant) {
414
+ async function visit(name, preferredVariant, target) {
294
415
  const normalizedName = normalizeBlockName(name)
295
416
  const meta = await getMeta(normalizedName)
296
417
  const variantName = preferredVariant || meta.install.defaultVariant
@@ -301,36 +422,61 @@ export function createBlocksService({
301
422
  }
302
423
 
303
424
  const key = toBlockKey({ name: normalizedName, variant: variantName })
425
+ const traversalKey = createTraversalKey(normalizedName, variantName, target)
304
426
 
305
- if (visited.has(key)) {
427
+ if (propagated.has(traversalKey)) {
428
+ itemsByKey.get(key)?.targets.add(target)
306
429
  return
307
430
  }
308
431
 
309
- if (resolving.has(key)) {
432
+ if (resolving.has(traversalKey)) {
310
433
  throw new Error(`Dependency cycle detected at "${key}"`)
311
434
  }
312
435
 
313
- resolving.add(key)
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)
314
451
 
315
452
  for (const dependency of variant.dependencies?.components ?? []) {
316
- await visit(dependency.name, dependency.variant || variantName)
453
+ await visit(dependency.name, dependency.variant || variantName, target)
317
454
  }
318
455
 
319
- resolving.delete(key)
320
- visited.add(key)
321
- plan.push({
322
- name: normalizedName,
323
- variant: variantName,
324
- meta,
325
- installVariant: variant,
326
- })
456
+ resolving.delete(traversalKey)
457
+ propagated.add(traversalKey)
458
+
459
+ if (!orderedKeySet.has(key)) {
460
+ orderedKeySet.add(key)
461
+ orderedKeys.push(key)
462
+ }
327
463
  }
328
464
 
329
465
  for (const block of rootBlocks) {
330
- await visit(block.name, block.variant)
466
+ await visit(block.name, block.variant, block.target)
331
467
  }
332
468
 
333
- return plan
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
+ })
334
480
  }
335
481
 
336
482
  function summarizePlan(plan) {
@@ -340,7 +486,7 @@ export function createBlocksService({
340
486
  const warnings = []
341
487
 
342
488
  for (const item of plan) {
343
- const sharedFiles = item.meta.install.sharedFiles ?? []
489
+ const sharedFiles = item.selectedSharedFiles
344
490
 
345
491
  if (sharedFiles.length > 0) {
346
492
  warnings.push(`Block "${item.name}" contains ${sharedFiles.length} sharedFiles entries. raw-only mode does not install them yet, skipping.`)
@@ -419,7 +565,7 @@ export function createBlocksService({
419
565
  for (const item of plan) {
420
566
  logger.info(`install ${item.name}@${item.variant}`)
421
567
 
422
- for (const rule of item.installVariant.files ?? []) {
568
+ for (const rule of item.selectedFiles) {
423
569
  await installFile(item.name, rule)
424
570
  }
425
571
  }
@@ -449,7 +595,7 @@ export function createBlocksService({
449
595
  for (const item of [...items].reverse()) {
450
596
  logger.info(`remove ${item.name}@${item.variant}`)
451
597
 
452
- for (const rule of item.installVariant.files ?? []) {
598
+ for (const rule of item.selectedFiles) {
453
599
  const targetPath = resolveTargetPath(projectRoot, rule)
454
600
  const relativeTargetPath = path.relative(projectRoot, targetPath) || path.basename(targetPath)
455
601
 
@@ -477,8 +623,8 @@ export function createBlocksService({
477
623
  return blocks
478
624
  }
479
625
 
480
- async function addBlock(rawName) {
481
- const result = await addBlocks([rawName])
626
+ async function addBlock(rawName, options = {}) {
627
+ const result = await addBlocks([rawName], options)
482
628
 
483
629
  return {
484
630
  block: result.blocks[0],
@@ -487,17 +633,24 @@ export function createBlocksService({
487
633
  }
488
634
  }
489
635
 
490
- async function addBlocks(rawNames) {
491
- const names = normalizeRequestedBlockNames(rawNames)
636
+ async function addBlocks(rawNames, options = {}) {
637
+ const target = normalizeBlockTarget(options.target, 'block target')
638
+ const requestedBlocks = normalizeRequestedBlocks(rawNames, { target })
492
639
  const rootBlocks = []
493
640
 
494
- for (const name of names) {
495
- const meta = await getMeta(name)
641
+ for (const requestedBlock of requestedBlocks) {
642
+ const meta = await getMeta(requestedBlock.name)
496
643
 
497
644
  rootBlocks.push({
498
- name,
499
- variant: meta.install.defaultVariant,
645
+ name: requestedBlock.name,
646
+ variant: requestedBlock.variant,
647
+ target: requestedBlock.target,
648
+ config: { ...requestedBlock.config },
500
649
  })
650
+
651
+ if (requestedBlock.variant && !meta.install?.variants?.[requestedBlock.variant]) {
652
+ throw new Error(`Variant "${requestedBlock.variant}" is not available for block "${requestedBlock.name}"`)
653
+ }
501
654
  }
502
655
 
503
656
  const plan = await resolveInstallPlan(rootBlocks)
@@ -505,13 +658,13 @@ export function createBlocksService({
505
658
  await installPlan(plan)
506
659
 
507
660
  const { configPath, config, blocks } = loadProjectConfig(projectRoot)
508
- const nextBlocks = dedupeExplicitBlocks([
509
- ...blocks.filter(block => !names.includes(block.name)),
661
+ const nextBlocks = dedupeBlocks([
662
+ ...blocks.filter(block => !requestedBlocks.some(requestedBlock => requestedBlock.name === block.name)),
510
663
  ...rootBlocks,
511
664
  ])
512
665
 
513
666
  saveProjectConfig(configPath, config, nextBlocks)
514
- logger.info(`added ${rootBlocks.map(block => `${block.name}@${block.variant}`).join(', ')}`)
667
+ logger.info(`added ${rootBlocks.map(formatConfiguredBlock).join(', ')}`)
515
668
 
516
669
  return {
517
670
  blocks: rootBlocks,
@@ -542,8 +695,26 @@ export function createBlocksService({
542
695
  const beforePlan = await resolveInstallPlan(blocks)
543
696
  const remainingBlocks = blocks.filter(block => !names.includes(block.name))
544
697
  const afterPlan = remainingBlocks.length > 0 ? await resolveInstallPlan(remainingBlocks) : []
545
- const afterKeys = new Set(afterPlan.map(toBlockKey))
546
- const removedItems = beforePlan.filter(item => !afterKeys.has(toBlockKey(item)))
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
+ })
547
718
 
548
719
  await removePlanItems(removedItems)
549
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>