@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newlogic-digital/cli",
3
- "version": "1.5.0-next.5",
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,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 parseBlockSpecifier(rawValue, {
87
- label = 'block name',
88
- allowObject = false,
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
- assertBlockName(name, 'configured block name')
92
+ const target = `${rawValue}`.trim().toLowerCase()
95
93
 
96
- if (rawValue.variant != null && !variant) {
97
- throw new Error(`Configured block "${name}" has an empty variant`)
98
- }
94
+ if (!target) {
95
+ throw new Error(`Invalid ${label}: "${rawValue}"`)
96
+ }
99
97
 
100
- return {
101
- name,
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 array`)
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((entry, index) => {
199
- if (typeof entry !== 'string' && (!entry || typeof entry !== 'object' || Array.isArray(entry))) {
200
- throw new Error(`Invalid block config entry at index ${index}`)
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 parseBlockSpecifier(entry, {
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: dedupeExplicitBlocks(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: blocks.map(block => block.variantExplicit ? `${block.name}@${block.variant}` : block.name),
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 dedupeExplicitBlocks(blocks) {
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, label = 'block name') {
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 visited = new Set()
408
+ const orderedKeys = []
409
+ const orderedKeySet = new Set()
339
410
  const resolving = new Set()
340
- const plan = []
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 (visited.has(key)) {
427
+ if (propagated.has(traversalKey)) {
428
+ itemsByKey.get(key)?.targets.add(target)
355
429
  return
356
430
  }
357
431
 
358
- if (resolving.has(key)) {
432
+ if (resolving.has(traversalKey)) {
359
433
  throw new Error(`Dependency cycle detected at "${key}"`)
360
434
  }
361
435
 
362
- 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)
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(key)
369
- visited.add(key)
370
- plan.push({
371
- name: normalizedName,
372
- variant: variantName,
373
- meta,
374
- installVariant: variant,
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 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
+ })
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.meta.install.sharedFiles ?? []
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.installVariant.files ?? []) {
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.installVariant.files ?? []) {
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 requestedBlocks = normalizeRequestedBlocks(rawNames)
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
- variantExplicit: requestedBlock.variantExplicit,
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 = dedupeExplicitBlocks([
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(block => block.variantExplicit ? `${block.name}@${block.variant}` : block.name).join(', ')}`)
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 afterKeys = new Set(afterPlan.map(toBlockKey))
600
- 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
+ })
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>