@newlogic-digital/cli 1.5.0-next.5 → 1.5.0-next.7

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