@newlogic-digital/cli 1.4.1 → 1.5.0-next.2

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
@@ -2,13 +2,76 @@
2
2
 
3
3
  import init from './src/commands/init/index.mjs'
4
4
  import cms from './src/commands/cms/index.mjs'
5
+ import blocks from './src/commands/blocks/index.mjs'
5
6
  import { styleText } from 'node:util'
6
7
  import { version, name } from './src/utils.mjs'
7
8
 
9
+ const knownCommands = ['init', 'cms', 'blocks']
10
+
8
11
  function normalizeOptionName(name) {
9
12
  return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
10
13
  }
11
14
 
15
+ function getLevenshteinDistance(left, right) {
16
+ const distances = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0))
17
+
18
+ for (let row = 0; row <= left.length; row++) {
19
+ distances[row][0] = row
20
+ }
21
+
22
+ for (let column = 0; column <= right.length; column++) {
23
+ distances[0][column] = column
24
+ }
25
+
26
+ for (let row = 1; row <= left.length; row++) {
27
+ for (let column = 1; column <= right.length; column++) {
28
+ const substitutionCost = left[row - 1] === right[column - 1] ? 0 : 1
29
+
30
+ distances[row][column] = Math.min(
31
+ distances[row - 1][column] + 1,
32
+ distances[row][column - 1] + 1,
33
+ distances[row - 1][column - 1] + substitutionCost,
34
+ )
35
+ }
36
+ }
37
+
38
+ return distances[left.length][right.length]
39
+ }
40
+
41
+ function getSuggestedCommand(command) {
42
+ const normalizedCommand = `${command ?? ''}`.trim().toLowerCase()
43
+
44
+ if (!normalizedCommand) {
45
+ return undefined
46
+ }
47
+
48
+ const suggestion = knownCommands
49
+ .map(candidate => ({ candidate, distance: getLevenshteinDistance(normalizedCommand, candidate) }))
50
+ .sort((left, right) => left.distance - right.distance)[0]
51
+
52
+ if (suggestion && suggestion.distance <= 2) {
53
+ return suggestion.candidate
54
+ }
55
+
56
+ return undefined
57
+ }
58
+
59
+ function printUnknownCommand(command) {
60
+ const suggestion = getSuggestedCommand(command)
61
+ const lines = [
62
+ `${styleText(['red', 'bold'], 'error')} Unknown command ${styleText(['yellow', 'bold'], `"${command}"`)}`,
63
+ ]
64
+
65
+ if (suggestion) {
66
+ lines.push(`${styleText(['cyan', 'bold'], 'hint')} Did you mean ${styleText(['green', 'bold'], `"${suggestion}"`)}?`)
67
+ }
68
+
69
+ lines.push(`${styleText(['white', 'bold'], 'available')} ${knownCommands.map(command => styleText('green', command)).join(', ')}`)
70
+ lines.push(`${styleText(['cyan', 'bold'], 'help')} Run ${styleText('green', 'newlogic')} to see full usage`)
71
+
72
+ console.log(lines.join('\n'))
73
+ }
74
+
12
75
  function parseCommandArgs(args) {
13
76
  const positionals = []
14
77
  const options = {}
@@ -60,30 +123,44 @@ const command = rawArgs[0]
60
123
 
61
124
  if (!command) {
62
125
  console.log(`
63
- ${styleText('blue', `${name} v${version}`)}
64
-
65
- Usage:
66
-
67
- -- init --
68
- ${styleText('green', 'newlogic init')} - Creates a new project in current directory
69
- ${styleText('green', 'newlogic init')} ${styleText('yellow', '<directory>')} - Creates a new project in new directory with the name ${styleText('yellow', '<directory>')}
70
- ${styleText('green', 'newlogic init ui')} - Creates a new ${styleText('blue', '@newlogic-digital/ui')} project in current directory
71
- ${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} - Creates a new ${styleText('blue', '@newlogic-digital/ui')} project in new directory with the name ${styleText('yellow', '<directory>')}
72
- ${styleText('green', 'newlogic init cms')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} project in current directory
73
- ${styleText('green', 'newlogic init cms')} ${styleText('yellow', '<directory>')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} project in new directory with the name ${styleText('yellow', '<directory>')}
74
- ${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '--branch=dev --clone=https --scope=blank --git --remote=<git-url> --install')}
75
- ${styleText('green', 'newlogic init cms')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '--branch=dev --clone=https --variant=cms-web --install --prepare --dev --migrations')}
76
- ${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '-y')} - Runs with default options without prompts
77
-
78
- -- cms --
79
- ${styleText('green', 'newlogic cms prepare')} - Copies templates and components from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')}
80
- ${styleText('green', 'newlogic cms prepare views')} - Copies views from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
81
- ${styleText('green', 'newlogic cms prepare components')} - Copies components from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
82
- ${styleText('green', 'newlogic cms new-component')} ${styleText('yellow', '<name>')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} section with name ${styleText('yellow', '<name>')}
83
- ${styleText('red', 'newlogic cms prepare templates')} - (deprecated) Copies templates from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
84
- ${styleText('red', 'newlogic cms prepare sections')} - (deprecated) Copies sections from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
85
- ${styleText('red', 'newlogic cms new-section')} ${styleText('yellow', '<name>')} - (deprecated) Creates a new ${styleText('blue', '@newlogic-digital/cms')} section with name ${styleText('yellow', '<name>')}
86
- `)
126
+ ${styleText('blue', `${name} v${version}`)}
127
+
128
+ Usage:
129
+
130
+ -- init --
131
+ ${styleText('green', 'newlogic init')} - Creates a new project in current directory
132
+ ${styleText('green', 'newlogic init')} ${styleText('yellow', '<directory>')} - Creates a new project in new directory with the name ${styleText('yellow', '<directory>')}
133
+ ${styleText('green', 'newlogic init ui')} - Creates a new ${styleText('blue', '@newlogic-digital/ui')} project in current directory
134
+ ${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} - Creates a new ${styleText('blue', '@newlogic-digital/ui')} project in new directory with the name ${styleText('yellow', '<directory>')}
135
+ ${styleText('green', 'newlogic init cms')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} project in current directory
136
+ ${styleText('green', 'newlogic init cms')} ${styleText('yellow', '<directory>')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} project in new directory with the name ${styleText('yellow', '<directory>')}
137
+ ${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '--branch=dev --clone=https --scope=blank --git --remote=<git-url> --install')}
138
+ ${styleText('green', 'newlogic init cms')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '--branch=dev --clone=https --variant=cms-web --install --prepare --dev --migrations')}
139
+ ${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '-y')} - Runs with default options without prompts
140
+
141
+ -- cms --
142
+ ${styleText('green', 'newlogic cms prepare')} - Copies templates and components from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')}
143
+ ${styleText('green', 'newlogic cms prepare views')} - Copies views from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
144
+ ${styleText('green', 'newlogic cms prepare components')} - Copies components from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
145
+ ${styleText('green', 'newlogic cms new-component')} ${styleText('yellow', '<name>')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} section with name ${styleText('yellow', '<name>')}
146
+ ${styleText('red', 'newlogic cms prepare templates')} - (deprecated) Copies templates from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
147
+ ${styleText('red', 'newlogic cms prepare sections')} - (deprecated) Copies sections from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
148
+ ${styleText('red', 'newlogic cms new-section')} ${styleText('yellow', '<name>')} - (deprecated) Creates a new ${styleText('blue', '@newlogic-digital/cms')} section with name ${styleText('yellow', '<name>')}
149
+
150
+ -- blocks --
151
+ ${styleText('green', 'newlogic blocks list')} - Lists all available installable blocks with descriptions
152
+ ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', '<name...>')} - Installs one or more blocks by kebab-case or PascalCase name
153
+ ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies
154
+ ${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}
155
+ ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'about-accordion')}
156
+ ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'AboutAccordion')}
157
+ ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}
158
+ ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'about-accordion')}
159
+ ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}
160
+ ${styleText('green', 'newlogic blocks update')}
161
+ `)
162
+
163
+ process.exit(0)
87
164
  }
88
165
 
89
166
  if (command === 'init') {
@@ -101,3 +178,16 @@ if (command === 'cms') {
101
178
 
102
179
  await cms(action, name)
103
180
  }
181
+
182
+ if (command === 'blocks') {
183
+ const { positionals } = parseCommandArgs(rawArgs.slice(1))
184
+ const action = positionals[0]
185
+ const names = positionals.slice(1)
186
+
187
+ await blocks(action, names)
188
+ }
189
+
190
+ if (command && !knownCommands.includes(command)) {
191
+ printUnknownCommand(command)
192
+ process.exit(1)
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newlogic-digital/cli",
3
- "version": "1.4.1",
3
+ "version": "1.5.0-next.2",
4
4
  "main": "index.mjs",
5
5
  "bin": {
6
6
  "newlogic-cli": "index.mjs",
@@ -8,10 +8,13 @@
8
8
  },
9
9
  "scripts": {
10
10
  "oxlint": "oxlint",
11
- "oxlint-fix": "oxlint --fix"
11
+ "oxlint-fix": "oxlint --fix",
12
+ "test": "node --test",
13
+ "publish-next": "npm publish --tag next"
12
14
  },
13
15
  "dependencies": {
14
16
  "@clack/prompts": "^1.1.0",
17
+ "ajv": "^8.17.1",
15
18
  "dedent": "^1.7.2",
16
19
  "es-toolkit": "^1.45.1",
17
20
  "fast-glob": "^3.3.3",
@@ -0,0 +1,195 @@
1
+ import { styleText } from 'node:util'
2
+ import { createBlocksService } from './service.mjs'
3
+
4
+ function label(color, text) {
5
+ return styleText([color, 'bold'], text)
6
+ }
7
+
8
+ function highlightQuoted(text) {
9
+ return text.replace(/"([^"]+)"/g, (_, value) => styleText(['yellow', 'bold'], `"${value}"`))
10
+ }
11
+
12
+ function highlightTokens(text) {
13
+ return highlightQuoted(text)
14
+ .replaceAll('newlogic.config.json', styleText(['yellow', 'bold'], 'newlogic.config.json'))
15
+ .replaceAll('composer.json', styleText(['yellow', 'bold'], 'composer.json'))
16
+ .replaceAll('package.json', styleText(['yellow', 'bold'], 'package.json'))
17
+ .replaceAll('sharedFiles', styleText(['magenta', 'bold'], 'sharedFiles'))
18
+ .replace(/\bcomposer\b/g, styleText(['blue', 'bold'], 'composer'))
19
+ .replace(/\bnpm\b/g, styleText(['blue', 'bold'], 'npm'))
20
+ }
21
+
22
+ function formatErrorMessage(message) {
23
+ let nextMessage = highlightTokens(message)
24
+
25
+ if (nextMessage.startsWith('Command failed: ')) {
26
+ nextMessage = `Command failed: ${styleText(['red', 'bold'], nextMessage.slice(16))}`
27
+ }
28
+
29
+ if (nextMessage.startsWith('Request failed for ')) {
30
+ nextMessage = nextMessage.replace(
31
+ /^Request failed for ([^(]+) \((.+)\)$/,
32
+ (_, url, status) => `Request failed for ${styleText('cyan', url.trim())} ${styleText(['red', 'bold'], `(${status})`)}`,
33
+ )
34
+ }
35
+
36
+ return nextMessage
37
+ }
38
+
39
+ function createSilentLogger() {
40
+ return {
41
+ info() {},
42
+ warn() {},
43
+ }
44
+ }
45
+
46
+ function formatInfoMessage(message) {
47
+ if (message.startsWith('install ')) {
48
+ return `${label('cyan', 'install')} ${styleText(['white', 'bold'], message.slice(8))}`
49
+ }
50
+
51
+ if (message.startsWith('remove ')) {
52
+ return `${label('cyan', 'remove')} ${styleText(['white', 'bold'], message.slice(7))}`
53
+ }
54
+
55
+ if (message.startsWith('write ')) {
56
+ return ` ${label('green', 'write')} ${styleText('white', message.slice(6))}`
57
+ }
58
+
59
+ if (message.startsWith('delete ')) {
60
+ return ` ${label('red', 'delete')} ${styleText('white', message.slice(7))}`
61
+ }
62
+
63
+ if (message.startsWith('skip missing ')) {
64
+ return ` ${label('yellow', 'skip')} ${styleText('dim', message.slice(13))} ${styleText('dim', '(missing)')}`
65
+ }
66
+
67
+ if (message.startsWith('skip ')) {
68
+ return ` ${label('yellow', 'skip')} ${styleText('dim', message.slice(5))}`
69
+ }
70
+
71
+ if (message.startsWith('composer require ')) {
72
+ return `${label('blue', 'composer')} ${styleText(['white', 'bold'], message.slice(17))}`
73
+ }
74
+
75
+ if (message.startsWith('npm install ')) {
76
+ return `${label('blue', 'npm')} ${styleText(['white', 'bold'], message.slice(12))}`
77
+ }
78
+
79
+ if (message.startsWith('postInstall ')) {
80
+ return `${label('magenta', 'hook')} ${styleText(['white', 'bold'], message.slice(12))}`
81
+ }
82
+
83
+ if (message.startsWith('added ')) {
84
+ return `${label('green', 'done')} ${styleText(['white', 'bold'], message.slice(6))}`
85
+ }
86
+
87
+ if (message.startsWith('removed ')) {
88
+ return `${label('green', 'done')} ${styleText(['white', 'bold'], message.slice(8))}`
89
+ }
90
+
91
+ if (message.startsWith('updated ')) {
92
+ return `${label('green', 'done')} ${styleText(['white', 'bold'], message.slice(8))}`
93
+ }
94
+
95
+ if (message.startsWith('No blocks configured')) {
96
+ return `${label('yellow', 'info')} ${styleText('yellow', message)}`
97
+ }
98
+
99
+ return `${label('gray', 'info')} ${highlightTokens(message)}`
100
+ }
101
+
102
+ function createCliLogger() {
103
+ return {
104
+ info(message) {
105
+ console.log(formatInfoMessage(message))
106
+ },
107
+
108
+ warn(message) {
109
+ console.log(`${label('yellow', 'warn')} ${styleText('yellow', highlightTokens(message))}`)
110
+ },
111
+ }
112
+ }
113
+
114
+ function printBlocksList(blocks) {
115
+ const width = blocks.reduce((maxWidth, block) => Math.max(maxWidth, block.name.length), 0)
116
+
117
+ console.log(`${styleText(['blue', 'bold'], 'Available Blocks')} ${styleText('dim', `(${blocks.length})`)}`)
118
+ console.log('')
119
+
120
+ for (const block of blocks) {
121
+ const name = styleText(['green', 'bold'], block.name.padEnd(width))
122
+ const description = block.description
123
+ ? styleText('dim', block.description)
124
+ : styleText('dim', 'No description')
125
+
126
+ console.log(` ${name} ${description}`)
127
+ }
128
+ }
129
+
130
+ function printBlocksUsage() {
131
+ console.log([
132
+ styleText(['blue', 'bold'], 'newlogic blocks'),
133
+ '',
134
+ styleText(['white', 'bold'], 'Usage:'),
135
+ '',
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`,
138
+ ` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies`,
139
+ ` ${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}`,
140
+ '',
141
+ styleText(['white', 'bold'], 'Examples:'),
142
+ '',
143
+ ` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}`,
144
+ ` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}`,
145
+ ].join('\n'))
146
+ }
147
+
148
+ export default async function blocks(action, names = []) {
149
+ if (!action || action === 'help' || action === '--help') {
150
+ printBlocksUsage()
151
+ return
152
+ }
153
+
154
+ try {
155
+ if (action === 'list') {
156
+ const service = createBlocksService({ logger: createSilentLogger() })
157
+ const entries = await service.listBlocks()
158
+
159
+ printBlocksList(entries)
160
+ return
161
+ }
162
+
163
+ if (action === 'add') {
164
+ if (names.length === 0) {
165
+ throw new Error('Missing block name for "newlogic blocks add"')
166
+ }
167
+
168
+ const service = createBlocksService({ logger: createCliLogger() })
169
+ await service.addBlocks(names)
170
+ return
171
+ }
172
+
173
+ if (action === 'remove') {
174
+ if (names.length === 0) {
175
+ throw new Error('Missing block name for "newlogic blocks remove"')
176
+ }
177
+
178
+ const service = createBlocksService({ logger: createCliLogger() })
179
+ await service.removeBlocks(names)
180
+ return
181
+ }
182
+
183
+ if (action === 'update') {
184
+ const service = createBlocksService({ logger: createCliLogger() })
185
+ await service.updateBlocks()
186
+ return
187
+ }
188
+
189
+ throw new Error(`Unknown blocks action "${action}"`)
190
+ }
191
+ catch (error) {
192
+ console.log(`${label('red', 'error')} ${formatErrorMessage(error.message)}`)
193
+ process.exit(1)
194
+ }
195
+ }
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { resolveInside } from '../../utils.mjs'
4
+
5
+ const DEFAULT_REMOTE_SOURCE_BASE_URL = 'https://git.newlogic.cz/newlogic-digital/newlogic-blocks/-/raw/main/src'
6
+
7
+ function encodePath(relativePath) {
8
+ return relativePath
9
+ .split('/')
10
+ .filter(Boolean)
11
+ .map(segment => encodeURIComponent(segment))
12
+ .join('/')
13
+ }
14
+
15
+ function buildRemoteUrl(baseUrl, relativePath) {
16
+ return `${baseUrl}/${encodePath(relativePath)}`
17
+ }
18
+
19
+ async function fetchOrThrow(fetchImpl, url) {
20
+ const response = await fetchImpl(url)
21
+
22
+ if (!response.ok) {
23
+ throw new Error(`Request failed for ${url} (${response.status} ${response.statusText})`)
24
+ }
25
+
26
+ return response
27
+ }
28
+
29
+ async function fetchJson(fetchImpl, url) {
30
+ const response = await fetchOrThrow(fetchImpl, url)
31
+
32
+ try {
33
+ return await response.json()
34
+ }
35
+ catch (error) {
36
+ throw new Error(`Invalid JSON returned from ${url}: ${error.message}`)
37
+ }
38
+ }
39
+
40
+ async function fetchBuffer(fetchImpl, url) {
41
+ const response = await fetchOrThrow(fetchImpl, url)
42
+ const arrayBuffer = await response.arrayBuffer()
43
+
44
+ return Buffer.from(arrayBuffer)
45
+ }
46
+
47
+ function buildGeneratedIndex(componentsRoot) {
48
+ const blocks = fs.readdirSync(componentsRoot, { withFileTypes: true })
49
+ .filter(entry => entry.isDirectory())
50
+ .map(entry => entry.name)
51
+ .filter(name => fs.existsSync(path.join(componentsRoot, name, 'meta.json')))
52
+ .map((name) => {
53
+ const metaPath = path.join(componentsRoot, name, 'meta.json')
54
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
55
+
56
+ return {
57
+ name: meta.name,
58
+ description: meta.description ?? '',
59
+ }
60
+ })
61
+
62
+ return { blocks }
63
+ }
64
+
65
+ export function createRemoteBlocksRepository({ baseUrl = DEFAULT_REMOTE_SOURCE_BASE_URL, fetchImpl = fetch } = {}) {
66
+ return {
67
+ async getSchema() {
68
+ return await fetchJson(fetchImpl, buildRemoteUrl(baseUrl, 'schema.json'))
69
+ },
70
+
71
+ async getIndex() {
72
+ return await fetchJson(fetchImpl, buildRemoteUrl(baseUrl, 'index.json'))
73
+ },
74
+
75
+ async getMeta(name) {
76
+ return await fetchJson(fetchImpl, buildRemoteUrl(baseUrl, `components/${name}/meta.json`))
77
+ },
78
+
79
+ async getFileBuffer(name, sourcePath) {
80
+ return await fetchBuffer(fetchImpl, buildRemoteUrl(baseUrl, `components/${name}/${sourcePath}`))
81
+ },
82
+ }
83
+ }
84
+
85
+ export function createFilesystemBlocksRepository({ rootDir }) {
86
+ const sourceRoot = path.resolve(rootDir, 'src')
87
+ const componentsRoot = path.join(sourceRoot, 'components')
88
+
89
+ return {
90
+ async getSchema() {
91
+ const schemaPath = resolveInside(sourceRoot, 'schema.json')
92
+
93
+ return JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
94
+ },
95
+
96
+ async getIndex() {
97
+ const indexPath = path.join(sourceRoot, 'index.json')
98
+
99
+ if (fs.existsSync(indexPath)) {
100
+ return JSON.parse(fs.readFileSync(indexPath, 'utf8'))
101
+ }
102
+
103
+ return buildGeneratedIndex(componentsRoot)
104
+ },
105
+
106
+ async getMeta(name) {
107
+ const metaPath = resolveInside(componentsRoot, name, 'meta.json')
108
+
109
+ if (!fs.existsSync(metaPath)) {
110
+ throw new Error(`Missing meta.json for block "${name}" in ${componentsRoot}`)
111
+ }
112
+
113
+ return JSON.parse(fs.readFileSync(metaPath, 'utf8'))
114
+ },
115
+
116
+ async getFileBuffer(name, sourcePath) {
117
+ const filePath = resolveInside(componentsRoot, name, ...sourcePath.split('/'))
118
+
119
+ if (!fs.existsSync(filePath)) {
120
+ throw new Error(`Missing source file for block "${name}": ${sourcePath}`)
121
+ }
122
+
123
+ return fs.readFileSync(filePath)
124
+ },
125
+ }
126
+ }
@@ -0,0 +1,587 @@
1
+ import childProcess from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import Ajv2020 from 'ajv/dist/2020.js'
5
+ import fse from 'fs-extra'
6
+ import { resolveInside } from '../../utils.mjs'
7
+ import { createRemoteBlocksRepository } from './repository.mjs'
8
+
9
+ const CONFIG_FILE_NAME = 'newlogic.config.json'
10
+ const BLOCK_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
11
+
12
+ function defaultLogger() {
13
+ return {
14
+ info: message => console.log(message),
15
+ warn: message => console.warn(message),
16
+ }
17
+ }
18
+
19
+ function runOrThrow(command, args, options = {}) {
20
+ const result = childProcess.spawnSync(command, args, {
21
+ cwd: options.cwd,
22
+ stdio: 'inherit',
23
+ })
24
+
25
+ if (result.error) {
26
+ throw result.error
27
+ }
28
+
29
+ if (result.status !== 0) {
30
+ throw new Error(`Command failed: ${command} ${args.join(' ')}`)
31
+ }
32
+ }
33
+
34
+ function shellOrThrow(command, options = {}) {
35
+ const shell = process.env.SHELL || '/bin/sh'
36
+ const result = childProcess.spawnSync(shell, ['-lc', command], {
37
+ cwd: options.cwd,
38
+ stdio: 'inherit',
39
+ })
40
+
41
+ if (result.error) {
42
+ throw result.error
43
+ }
44
+
45
+ if (result.status !== 0) {
46
+ throw new Error(`Command failed: ${command}`)
47
+ }
48
+ }
49
+
50
+ function createDefaultCommandRunner(projectRoot) {
51
+ return {
52
+ async runComposerRequire(packages) {
53
+ runOrThrow('composer', ['require', ...packages], { cwd: projectRoot })
54
+ },
55
+
56
+ async runNpmInstall(packages) {
57
+ runOrThrow('npm', ['install', ...packages], { cwd: projectRoot })
58
+ },
59
+
60
+ async runPostInstallCommand(command) {
61
+ shellOrThrow(command, { cwd: projectRoot })
62
+ },
63
+ }
64
+ }
65
+
66
+ function normalizeBlockName(value) {
67
+ const trimmed = `${value ?? ''}`.trim()
68
+
69
+ if (!trimmed) {
70
+ return ''
71
+ }
72
+
73
+ return trimmed
74
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
75
+ .replace(/[\s_]+/g, '-')
76
+ .replace(/-+/g, '-')
77
+ .toLowerCase()
78
+ }
79
+
80
+ function assertBlockName(name, label = 'block name') {
81
+ if (!BLOCK_NAME_PATTERN.test(name)) {
82
+ throw new Error(`Invalid ${label}: "${name}"`)
83
+ }
84
+ }
85
+
86
+ function toBlockKey(block) {
87
+ return `${block.name}@${block.variant}`
88
+ }
89
+
90
+ function resolveTargetPath(projectRoot, rule) {
91
+ const destinationRoot = resolveInside(projectRoot, rule.destination)
92
+ const parentDirectory = rule.parentDirectory ? rule.parentDirectory.split('/').filter(Boolean) : []
93
+
94
+ return resolveInside(destinationRoot, ...parentDirectory, rule.fileName)
95
+ }
96
+
97
+ function cleanupEmptyDirectories(startDir, stopDir) {
98
+ let currentDir = startDir
99
+ const stopPath = path.resolve(stopDir)
100
+
101
+ while (currentDir.startsWith(stopPath) && currentDir !== stopPath) {
102
+ if (!fs.existsSync(currentDir)) {
103
+ currentDir = path.dirname(currentDir)
104
+ continue
105
+ }
106
+
107
+ const stat = fs.lstatSync(currentDir)
108
+
109
+ if (!stat.isDirectory()) {
110
+ currentDir = path.dirname(currentDir)
111
+ continue
112
+ }
113
+
114
+ if (fs.readdirSync(currentDir).length > 0) {
115
+ break
116
+ }
117
+
118
+ fs.rmdirSync(currentDir)
119
+ currentDir = path.dirname(currentDir)
120
+ }
121
+ }
122
+
123
+ function loadProjectConfig(projectRoot) {
124
+ const configPath = path.join(projectRoot, CONFIG_FILE_NAME)
125
+
126
+ if (!fs.existsSync(configPath)) {
127
+ return {
128
+ configPath,
129
+ config: { blocks: [] },
130
+ blocks: [],
131
+ }
132
+ }
133
+
134
+ let config
135
+
136
+ try {
137
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
138
+ }
139
+ catch (error) {
140
+ throw new Error(`Invalid ${CONFIG_FILE_NAME}: ${error.message}`)
141
+ }
142
+
143
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
144
+ throw new Error(`${CONFIG_FILE_NAME} must contain a JSON object`)
145
+ }
146
+
147
+ const blocksRaw = config.blocks ?? []
148
+
149
+ if (!Array.isArray(blocksRaw)) {
150
+ throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an array`)
151
+ }
152
+
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()
160
+
161
+ assertBlockName(name, 'configured block name')
162
+
163
+ if (!variant) {
164
+ throw new Error(`Configured block "${name}" is missing a variant`)
165
+ }
166
+
167
+ return { name, variant }
168
+ })
169
+
170
+ return { configPath, config, blocks: dedupeExplicitBlocks(blocks) }
171
+ }
172
+
173
+ function saveProjectConfig(configPath, config, blocks) {
174
+ const nextConfig = {
175
+ ...config,
176
+ blocks,
177
+ }
178
+
179
+ fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`)
180
+ }
181
+
182
+ function dedupeExplicitBlocks(blocks) {
183
+ const byName = new Map()
184
+
185
+ for (const block of blocks) {
186
+ byName.set(block.name, block)
187
+ }
188
+
189
+ return [...byName.values()]
190
+ }
191
+
192
+ function normalizeRequestedBlockNames(rawNames, label = 'block name') {
193
+ if (!Array.isArray(rawNames) || rawNames.length === 0) {
194
+ throw new Error(`Missing ${label}`)
195
+ }
196
+
197
+ const normalizedNames = rawNames.map((rawName) => {
198
+ const name = normalizeBlockName(rawName)
199
+
200
+ assertBlockName(name, label)
201
+
202
+ return name
203
+ })
204
+
205
+ return [...new Set(normalizedNames)]
206
+ }
207
+
208
+ async function createMetaValidator(repository) {
209
+ const schema = await repository.getSchema()
210
+ const ajv = new Ajv2020({
211
+ allErrors: true,
212
+ strict: false,
213
+ validateFormats: false,
214
+ })
215
+ const validate = ajv.compile(schema)
216
+
217
+ return (meta, sourceLabel) => {
218
+ const valid = validate(meta)
219
+
220
+ if (valid) {
221
+ return
222
+ }
223
+
224
+ const issues = (validate.errors ?? [])
225
+ .map((error) => {
226
+ const location = error.instancePath || '/'
227
+ return `${location} ${error.message}`.trim()
228
+ })
229
+ .join('; ')
230
+
231
+ throw new Error(`Invalid metadata for ${sourceLabel}: ${issues}`)
232
+ }
233
+ }
234
+
235
+ function ensureIndex(index) {
236
+ if (!index || typeof index !== 'object' || Array.isArray(index)) {
237
+ throw new Error('Invalid blocks index payload')
238
+ }
239
+
240
+ if (!Array.isArray(index.blocks)) {
241
+ throw new Error('Invalid blocks index payload: missing "blocks" array')
242
+ }
243
+
244
+ return index.blocks.map((block, indexPosition) => {
245
+ if (!block || typeof block !== 'object' || Array.isArray(block)) {
246
+ throw new Error(`Invalid block index entry at position ${indexPosition}`)
247
+ }
248
+
249
+ const name = normalizeBlockName(block.name)
250
+ const description = `${block.description ?? ''}`.trim()
251
+
252
+ assertBlockName(name)
253
+
254
+ return { name, description }
255
+ })
256
+ }
257
+
258
+ export function createBlocksService({
259
+ projectRoot = process.cwd(),
260
+ repository = createRemoteBlocksRepository(),
261
+ commandRunner = createDefaultCommandRunner(projectRoot),
262
+ logger = defaultLogger(),
263
+ } = {}) {
264
+ const metaCache = new Map()
265
+ let validatorPromise
266
+
267
+ async function validateMeta(meta, sourceLabel) {
268
+ validatorPromise ||= createMetaValidator(repository)
269
+ const validator = await validatorPromise
270
+
271
+ validator(meta, sourceLabel)
272
+ }
273
+
274
+ async function getMeta(name) {
275
+ const normalizedName = normalizeBlockName(name)
276
+
277
+ assertBlockName(normalizedName)
278
+
279
+ if (!metaCache.has(normalizedName)) {
280
+ const meta = await repository.getMeta(normalizedName)
281
+ await validateMeta(meta, `block "${normalizedName}"`)
282
+ metaCache.set(normalizedName, meta)
283
+ }
284
+
285
+ return metaCache.get(normalizedName)
286
+ }
287
+
288
+ async function resolveInstallPlan(rootBlocks) {
289
+ const visited = new Set()
290
+ const resolving = new Set()
291
+ const plan = []
292
+
293
+ async function visit(name, preferredVariant) {
294
+ const normalizedName = normalizeBlockName(name)
295
+ const meta = await getMeta(normalizedName)
296
+ const variantName = preferredVariant || meta.install.defaultVariant
297
+ const variant = meta.install?.variants?.[variantName]
298
+
299
+ if (!variant) {
300
+ throw new Error(`Variant "${variantName}" is not available for block "${normalizedName}"`)
301
+ }
302
+
303
+ const key = toBlockKey({ name: normalizedName, variant: variantName })
304
+
305
+ if (visited.has(key)) {
306
+ return
307
+ }
308
+
309
+ if (resolving.has(key)) {
310
+ throw new Error(`Dependency cycle detected at "${key}"`)
311
+ }
312
+
313
+ resolving.add(key)
314
+
315
+ for (const dependency of variant.dependencies?.components ?? []) {
316
+ await visit(dependency.name, dependency.variant || variantName)
317
+ }
318
+
319
+ resolving.delete(key)
320
+ visited.add(key)
321
+ plan.push({
322
+ name: normalizedName,
323
+ variant: variantName,
324
+ meta,
325
+ installVariant: variant,
326
+ })
327
+ }
328
+
329
+ for (const block of rootBlocks) {
330
+ await visit(block.name, block.variant)
331
+ }
332
+
333
+ return plan
334
+ }
335
+
336
+ function summarizePlan(plan) {
337
+ const composer = new Set()
338
+ const npm = new Set()
339
+ const postInstall = []
340
+ const warnings = []
341
+
342
+ for (const item of plan) {
343
+ const sharedFiles = item.meta.install.sharedFiles ?? []
344
+
345
+ if (sharedFiles.length > 0) {
346
+ warnings.push(`Block "${item.name}" contains ${sharedFiles.length} sharedFiles entries. raw-only mode does not install them yet, skipping.`)
347
+ }
348
+
349
+ for (const dependency of item.installVariant.dependencies?.composer ?? []) {
350
+ composer.add(dependency)
351
+ }
352
+
353
+ for (const dependency of item.installVariant.dependencies?.npm ?? []) {
354
+ npm.add(dependency)
355
+ }
356
+
357
+ for (const step of item.installVariant.postInstall ?? []) {
358
+ postInstall.push({
359
+ blockName: item.name,
360
+ step,
361
+ })
362
+ }
363
+ }
364
+
365
+ return {
366
+ composer: [...composer],
367
+ npm: [...npm],
368
+ postInstall,
369
+ warnings,
370
+ }
371
+ }
372
+
373
+ function validateProjectDependencies(summary) {
374
+ if (summary.composer.length > 0 && !fs.existsSync(path.join(projectRoot, 'composer.json'))) {
375
+ throw new Error('composer dependencies are required by blocks metadata, but composer.json is missing in the current project')
376
+ }
377
+
378
+ if (summary.npm.length > 0 && !fs.existsSync(path.join(projectRoot, 'package.json'))) {
379
+ throw new Error('npm dependencies are required by blocks metadata, but package.json is missing in the current project')
380
+ }
381
+ }
382
+
383
+ async function installFile(blockName, rule) {
384
+ const whenExists = rule.whenExists || 'fail'
385
+ const targetPath = resolveTargetPath(projectRoot, rule)
386
+ const relativeTargetPath = path.relative(projectRoot, targetPath) || path.basename(targetPath)
387
+
388
+ if (fs.existsSync(targetPath)) {
389
+ if (whenExists === 'skip') {
390
+ logger.info(`skip ${relativeTargetPath}`)
391
+ return
392
+ }
393
+
394
+ if (whenExists === 'fail') {
395
+ throw new Error(`Cannot install block "${blockName}": target already exists (${relativeTargetPath})`)
396
+ }
397
+
398
+ if (whenExists === 'overwrite') {
399
+ fse.removeSync(targetPath)
400
+ }
401
+ }
402
+
403
+ const contents = await repository.getFileBuffer(blockName, rule.source)
404
+
405
+ fse.ensureDirSync(path.dirname(targetPath))
406
+ fs.writeFileSync(targetPath, contents)
407
+ logger.info(`write ${relativeTargetPath}`)
408
+ }
409
+
410
+ async function installPlan(plan) {
411
+ const summary = summarizePlan(plan)
412
+
413
+ validateProjectDependencies(summary)
414
+
415
+ for (const warning of summary.warnings) {
416
+ logger.warn(warning)
417
+ }
418
+
419
+ for (const item of plan) {
420
+ logger.info(`install ${item.name}@${item.variant}`)
421
+
422
+ for (const rule of item.installVariant.files ?? []) {
423
+ await installFile(item.name, rule)
424
+ }
425
+ }
426
+
427
+ if (summary.composer.length > 0) {
428
+ logger.info(`composer require ${summary.composer.join(' ')}`)
429
+ await commandRunner.runComposerRequire(summary.composer)
430
+ }
431
+
432
+ if (summary.npm.length > 0) {
433
+ logger.info(`npm install ${summary.npm.join(' ')}`)
434
+ await commandRunner.runNpmInstall(summary.npm)
435
+ }
436
+
437
+ for (const entry of summary.postInstall) {
438
+ if (entry.step.type === 'info') {
439
+ logger.info(entry.step.value)
440
+ continue
441
+ }
442
+
443
+ logger.info(`postInstall ${entry.blockName}: ${entry.step.value}`)
444
+ await commandRunner.runPostInstallCommand(entry.step.value)
445
+ }
446
+ }
447
+
448
+ async function removePlanItems(items) {
449
+ for (const item of [...items].reverse()) {
450
+ logger.info(`remove ${item.name}@${item.variant}`)
451
+
452
+ for (const rule of item.installVariant.files ?? []) {
453
+ const targetPath = resolveTargetPath(projectRoot, rule)
454
+ const relativeTargetPath = path.relative(projectRoot, targetPath) || path.basename(targetPath)
455
+
456
+ if (!fs.existsSync(targetPath)) {
457
+ logger.info(`skip missing ${relativeTargetPath}`)
458
+ continue
459
+ }
460
+
461
+ fse.removeSync(targetPath)
462
+ cleanupEmptyDirectories(path.dirname(targetPath), projectRoot)
463
+ logger.info(`delete ${relativeTargetPath}`)
464
+ }
465
+ }
466
+ }
467
+
468
+ async function listBlocks() {
469
+ const index = await repository.getIndex()
470
+ const blocks = ensureIndex(index)
471
+ .sort((left, right) => left.name.localeCompare(right.name))
472
+
473
+ for (const block of blocks) {
474
+ logger.info(`${block.name} - ${block.description}`)
475
+ }
476
+
477
+ return blocks
478
+ }
479
+
480
+ async function addBlock(rawName) {
481
+ const result = await addBlocks([rawName])
482
+
483
+ return {
484
+ block: result.blocks[0],
485
+ plan: result.plan,
486
+ configBlocks: result.configBlocks,
487
+ }
488
+ }
489
+
490
+ async function addBlocks(rawNames) {
491
+ const names = normalizeRequestedBlockNames(rawNames)
492
+ const rootBlocks = []
493
+
494
+ for (const name of names) {
495
+ const meta = await getMeta(name)
496
+
497
+ rootBlocks.push({
498
+ name,
499
+ variant: meta.install.defaultVariant,
500
+ })
501
+ }
502
+
503
+ const plan = await resolveInstallPlan(rootBlocks)
504
+
505
+ await installPlan(plan)
506
+
507
+ const { configPath, config, blocks } = loadProjectConfig(projectRoot)
508
+ const nextBlocks = dedupeExplicitBlocks([
509
+ ...blocks.filter(block => !names.includes(block.name)),
510
+ ...rootBlocks,
511
+ ])
512
+
513
+ saveProjectConfig(configPath, config, nextBlocks)
514
+ logger.info(`added ${rootBlocks.map(block => `${block.name}@${block.variant}`).join(', ')}`)
515
+
516
+ return {
517
+ blocks: rootBlocks,
518
+ plan,
519
+ configBlocks: nextBlocks,
520
+ }
521
+ }
522
+
523
+ async function removeBlock(rawName) {
524
+ const result = await removeBlocks([rawName])
525
+
526
+ return {
527
+ removed: result.removed,
528
+ configBlocks: result.configBlocks,
529
+ }
530
+ }
531
+
532
+ async function removeBlocks(rawNames) {
533
+ const names = normalizeRequestedBlockNames(rawNames)
534
+ const { configPath, config, blocks } = loadProjectConfig(projectRoot)
535
+ const missingNames = names.filter(name => !blocks.some(block => block.name === name))
536
+
537
+ if (missingNames.length > 0) {
538
+ const suffix = missingNames.length === 1 ? 'is' : 'are'
539
+ throw new Error(`Block${missingNames.length === 1 ? '' : 's'} "${missingNames.join('", "')}" ${suffix} not installed in ${CONFIG_FILE_NAME}`)
540
+ }
541
+
542
+ const beforePlan = await resolveInstallPlan(blocks)
543
+ const remainingBlocks = blocks.filter(block => !names.includes(block.name))
544
+ 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)))
547
+
548
+ await removePlanItems(removedItems)
549
+ saveProjectConfig(configPath, config, remainingBlocks)
550
+ logger.info(`removed ${names.join(', ')}`)
551
+
552
+ return {
553
+ removed: removedItems,
554
+ configBlocks: remainingBlocks,
555
+ }
556
+ }
557
+
558
+ async function updateBlocks() {
559
+ const { blocks } = loadProjectConfig(projectRoot)
560
+
561
+ if (blocks.length === 0) {
562
+ logger.info(`No blocks configured in ${CONFIG_FILE_NAME}`)
563
+ return {
564
+ plan: [],
565
+ }
566
+ }
567
+
568
+ const plan = await resolveInstallPlan(blocks)
569
+
570
+ await installPlan(plan)
571
+ logger.info(`updated ${blocks.length} explicit block(s)`)
572
+
573
+ return { plan }
574
+ }
575
+
576
+ return {
577
+ addBlock,
578
+ addBlocks,
579
+ listBlocks,
580
+ removeBlock,
581
+ removeBlocks,
582
+ resolveInstallPlan,
583
+ updateBlocks,
584
+ }
585
+ }
586
+
587
+ export { normalizeBlockName }
@@ -0,0 +1,28 @@
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>
@@ -0,0 +1,59 @@
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>
@@ -0,0 +1,71 @@
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>
package/src/utils.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import childProcess from 'child_process'
2
2
  import fs from 'fs'
3
- import { dirname, resolve } from 'path'
3
+ import path from 'path'
4
4
  import { fileURLToPath } from 'url'
5
5
 
6
- const { version, name } = JSON.parse(fs.readFileSync(resolve(dirname((fileURLToPath(import.meta.url))), '../package.json')).toString())
6
+ const { version, name } = JSON.parse(fs.readFileSync(path.resolve(path.dirname((fileURLToPath(import.meta.url))), '../package.json')).toString())
7
7
 
8
8
  const execSync = (cmd) => {
9
9
  try {
@@ -34,4 +34,15 @@ const stripIndent = (string) => {
34
34
  return string.replace(regex, '')
35
35
  }
36
36
 
37
- export { execSync, stripIndent, version, name }
37
+ function resolveInside(rootDir, ...segments) {
38
+ const nextPath = path.resolve(rootDir, ...segments)
39
+ const relative = path.relative(rootDir, nextPath)
40
+
41
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
42
+ throw new Error(`Path escapes root: ${nextPath}`)
43
+ }
44
+
45
+ return nextPath
46
+ }
47
+
48
+ export { execSync, stripIndent, resolveInside, version, name }