@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 +114 -24
- package/package.json +5 -2
- package/src/commands/blocks/index.mjs +195 -0
- package/src/commands/blocks/repository.mjs +126 -0
- package/src/commands/blocks/service.mjs +587 -0
- package/src/templates/components/article/ArticleGridHorizontal.latte +28 -0
- package/src/templates/components/article/ArticleItem.latte +59 -0
- package/src/templates/components/header/HeaderNavLeft.latte +71 -0
- package/src/utils.mjs +14 -3
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 }
|