uipkge-ng 0.1.0
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/LICENSE +22 -0
- package/README.md +371 -0
- package/dist/args.js +7 -0
- package/dist/commands/add.js +216 -0
- package/dist/commands/build.js +121 -0
- package/dist/commands/diff.js +140 -0
- package/dist/commands/info.js +122 -0
- package/dist/commands/init.js +154 -0
- package/dist/commands/list.js +103 -0
- package/dist/commands/mcp.js +149 -0
- package/dist/commands/view.js +48 -0
- package/dist/config.js +56 -0
- package/dist/env.js +33 -0
- package/dist/errors.js +9 -0
- package/dist/extras.js +111 -0
- package/dist/files.js +49 -0
- package/dist/index.js +162 -0
- package/dist/layout.js +114 -0
- package/dist/output.js +26 -0
- package/dist/packages.js +68 -0
- package/dist/project.js +145 -0
- package/dist/prompts.js +27 -0
- package/dist/registry.js +187 -0
- package/dist/resolve.js +60 -0
- package/dist/setup.js +169 -0
- package/package.json +50 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { UipkgeError } from '../errors.js';
|
|
5
|
+
import { color, log, plural } from '../output.js';
|
|
6
|
+
import { DEFAULT_REGISTRY_URL, refKind } from '../registry.js';
|
|
7
|
+
const ITEM_SCHEMA = 'https://uipkge.dev/schema/registry-item.json';
|
|
8
|
+
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
9
|
+
/** Everything wrong with a registry source, as `where: problem` lines. Files are checked against `root`. */
|
|
10
|
+
export function validateRegistry(source, root) {
|
|
11
|
+
const problems = [];
|
|
12
|
+
if (!source || typeof source !== 'object' || !Array.isArray(source.items)) {
|
|
13
|
+
return ['registry: needs an "items" array'];
|
|
14
|
+
}
|
|
15
|
+
const { name, items } = source;
|
|
16
|
+
if (typeof name !== 'string' || !name)
|
|
17
|
+
problems.push('registry: needs a "name"');
|
|
18
|
+
const own = new Set(items.map(i => i?.name));
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
items.forEach((item, i) => {
|
|
21
|
+
const where = typeof item?.name === 'string' && item.name ? `items.${item.name}` : `items[${i}]`;
|
|
22
|
+
if (typeof item?.name !== 'string' || !NAME.test(item.name)) {
|
|
23
|
+
problems.push(`${where}: "name" must be lowercase letters, digits and dashes`);
|
|
24
|
+
}
|
|
25
|
+
else if (seen.has(item.name)) {
|
|
26
|
+
problems.push(`${where}: duplicate name`);
|
|
27
|
+
}
|
|
28
|
+
else if (item.name === 'registry') {
|
|
29
|
+
// registry.json is the index; an item called "registry" would overwrite it.
|
|
30
|
+
problems.push(`${where}: "registry" is reserved for the index`);
|
|
31
|
+
}
|
|
32
|
+
if (typeof item?.name === 'string')
|
|
33
|
+
seen.add(item.name);
|
|
34
|
+
if (!Array.isArray(item?.files)) {
|
|
35
|
+
problems.push(`${where}: needs a "files" array`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
item.files.forEach((file, j) => {
|
|
39
|
+
const at = `${where}.files[${j}]`;
|
|
40
|
+
if (typeof file?.path !== 'string' || !file.path)
|
|
41
|
+
return void problems.push(`${at}: needs a "path"`);
|
|
42
|
+
const abs = path.resolve(root, file.path);
|
|
43
|
+
if (path.relative(root, abs).startsWith('..') || path.isAbsolute(file.path)) {
|
|
44
|
+
problems.push(`${at}: path "${file.path}" is outside ${root}`);
|
|
45
|
+
}
|
|
46
|
+
else if (file.content === undefined && !existsSync(abs)) {
|
|
47
|
+
problems.push(`${at}: file "${file.path}" not found`);
|
|
48
|
+
}
|
|
49
|
+
if (file.target !== undefined) {
|
|
50
|
+
const target = String(file.target).replace(/^~\//, '');
|
|
51
|
+
if (path.isAbsolute(target) || path.normalize(target).startsWith('..')) {
|
|
52
|
+
problems.push(`${at}: target "${file.target}" must stay inside the project`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const key of ['dependencies', 'devDependencies']) {
|
|
58
|
+
const list = item?.[key];
|
|
59
|
+
if (list !== undefined && (!Array.isArray(list) || list.some(d => typeof d !== 'string' || !d.trim()))) {
|
|
60
|
+
problems.push(`${where}: "${key}" must be a list of package names`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const deps = item?.registryDependencies;
|
|
64
|
+
if (deps !== undefined) {
|
|
65
|
+
if (!Array.isArray(deps))
|
|
66
|
+
problems.push(`${where}: "registryDependencies" must be a list`);
|
|
67
|
+
else
|
|
68
|
+
for (const dep of deps) {
|
|
69
|
+
// Local files resolve against the installer's machine, not the registry — never valid in a published item.
|
|
70
|
+
if (typeof dep !== 'string' || refKind(dep) === 'file') {
|
|
71
|
+
problems.push(`${where}: registry dependency "${dep}" must be a name, @registry/name or an item URL`);
|
|
72
|
+
}
|
|
73
|
+
else if (refKind(dep) === 'name' && own.has(dep)) {
|
|
74
|
+
// Bare names mean uipkge components; an item of this registry needs a form that says so.
|
|
75
|
+
problems.push(`${where}: "${dep}" is an item of this registry — depend on it as @<registry>/${dep} or by URL`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return problems;
|
|
81
|
+
}
|
|
82
|
+
export async function runBuild(options) {
|
|
83
|
+
const root = path.resolve(options.cwd);
|
|
84
|
+
const sourceFile = path.resolve(root, options.registry);
|
|
85
|
+
if (!existsSync(sourceFile)) {
|
|
86
|
+
throw new UipkgeError(`No registry file at ${sourceFile}.`, 'Pass its path: `uipkge-ng build path/to/registry.json`.');
|
|
87
|
+
}
|
|
88
|
+
let source;
|
|
89
|
+
try {
|
|
90
|
+
source = JSON.parse(await readFile(sourceFile, 'utf8'));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new UipkgeError(`${options.registry} is not valid JSON: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
const problems = validateRegistry(source, root);
|
|
96
|
+
if (problems.length) {
|
|
97
|
+
throw new UipkgeError(`${options.registry} has ${plural(problems.length, 'problem')}:\n${problems.map(p => ` • ${p}`).join('\n')}`, 'Nothing was written.');
|
|
98
|
+
}
|
|
99
|
+
// Read everything before writing anything, so a failure leaves the output untouched.
|
|
100
|
+
const built = await Promise.all(source.items.map(async (item) => ({
|
|
101
|
+
$schema: ITEM_SCHEMA,
|
|
102
|
+
...item,
|
|
103
|
+
// Bare names are uipkge components: pin them to uipkge so they resolve the same in every project.
|
|
104
|
+
...(item.registryDependencies && {
|
|
105
|
+
registryDependencies: item.registryDependencies.map(d => (refKind(d) === 'name' ? `${DEFAULT_REGISTRY_URL}/${d}.json` : d)),
|
|
106
|
+
}),
|
|
107
|
+
files: await Promise.all(item.files.map(async (f) => ({ ...f, content: f.content ?? (await readFile(path.resolve(root, f.path), 'utf8')) }))),
|
|
108
|
+
})));
|
|
109
|
+
const outDir = path.resolve(root, options.output);
|
|
110
|
+
await mkdir(outDir, { recursive: true });
|
|
111
|
+
for (const item of built)
|
|
112
|
+
await writeFile(path.join(outDir, `${item.name}.json`), `${JSON.stringify(item, null, 2)}\n`, 'utf8');
|
|
113
|
+
// The index lists files without their content, like the uipkge registry does.
|
|
114
|
+
const index = { ...source, items: source.items.map(item => ({ ...item, files: item.files.map(({ content: _c, ...f }) => f) })) };
|
|
115
|
+
await writeFile(path.join(outDir, 'registry.json'), `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
|
116
|
+
const rel = path.relative(root, outDir) || '.';
|
|
117
|
+
log.success(`Built ${plural(built.length, 'item')} into ${rel}/`);
|
|
118
|
+
for (const item of built)
|
|
119
|
+
log.info(` ${color.green('✔')} ${item.name}.json ${color.dim(`(${plural(item.files.length, 'file')})`)}`);
|
|
120
|
+
log.info(color.dim(`Serve ${rel}/ and use "https://<host>/<path>/{name}.json" under "registries" in components.json.`));
|
|
121
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { createTwoFilesPatch } from 'diff';
|
|
5
|
+
import { registryFor, requireConfig } from '../config.js';
|
|
6
|
+
import { isItemInstalled, targetedFiles, normalizeEol } from '../files.js';
|
|
7
|
+
import { adaptItem, resolveLayout } from '../layout.js';
|
|
8
|
+
import { color, log, plural } from '../output.js';
|
|
9
|
+
import { loadProject, resolveTarget } from '../project.js';
|
|
10
|
+
import { refKind } from '../registry.js';
|
|
11
|
+
import { assertKnownNames } from '../resolve.js';
|
|
12
|
+
/** CRLF/LF differences are checkout noise, not changes. */
|
|
13
|
+
export { normalizeEol };
|
|
14
|
+
/** Compares each of the item's files on disk with the registry's copy. */
|
|
15
|
+
export async function compareItem(root, item) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const f of targetedFiles(item)) {
|
|
18
|
+
const abs = resolveTarget(root, f.target);
|
|
19
|
+
const file = path.relative(root, abs);
|
|
20
|
+
if (!existsSync(abs)) {
|
|
21
|
+
out.push({ file, status: 'missing' });
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const local = normalizeEol(await readFile(abs, 'utf8'));
|
|
25
|
+
const remote = normalizeEol(f.content ?? '');
|
|
26
|
+
out.push(local === remote
|
|
27
|
+
? { file, status: 'same' }
|
|
28
|
+
: { file, status: 'changed', patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, local, remote, 'local', 'registry') });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** Colours a unified diff for the terminal (no-op without colour support). */
|
|
33
|
+
export function colorPatch(patch) {
|
|
34
|
+
return patch
|
|
35
|
+
.split('\n')
|
|
36
|
+
.filter(line => !line.startsWith('====')) // jsdiff's "Index:" banner separator
|
|
37
|
+
.map(line => {
|
|
38
|
+
if (line.startsWith('+++') || line.startsWith('---'))
|
|
39
|
+
return color.bold(line);
|
|
40
|
+
if (line.startsWith('@@'))
|
|
41
|
+
return color.cyan(line);
|
|
42
|
+
if (line.startsWith('+'))
|
|
43
|
+
return color.green(line);
|
|
44
|
+
if (line.startsWith('-'))
|
|
45
|
+
return color.red(line);
|
|
46
|
+
return line;
|
|
47
|
+
})
|
|
48
|
+
.join('\n');
|
|
49
|
+
}
|
|
50
|
+
export async function runDiff(options) {
|
|
51
|
+
const project = await loadProject(options.cwd);
|
|
52
|
+
const { root } = project;
|
|
53
|
+
const config = await requireConfig(root);
|
|
54
|
+
const registry = registryFor(root, config, path.resolve(options.cwd));
|
|
55
|
+
const layout = resolveLayout(root, config.aliases);
|
|
56
|
+
if (options.name) {
|
|
57
|
+
if (refKind(options.name) === 'name')
|
|
58
|
+
assertKnownNames([options.name], new Set((await registry.index()).items.map(i => i.name)));
|
|
59
|
+
await diffOne(root, adaptItem(await registry.item(options.name), layout), options.name);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await diffAll(root, registry, Object.keys(config.registries ?? {}), layout);
|
|
63
|
+
}
|
|
64
|
+
async function diffOne(root, item, ref) {
|
|
65
|
+
const files = await compareItem(root, item);
|
|
66
|
+
if (files.every(f => f.status === 'missing')) {
|
|
67
|
+
log.info(`\`${ref}\` is not installed. Add it with \`uipkge-ng add ${ref}\`.`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const changed = files.filter(f => f.status === 'changed');
|
|
71
|
+
const missing = files.filter(f => f.status === 'missing');
|
|
72
|
+
for (const f of changed)
|
|
73
|
+
if (f.status === 'changed')
|
|
74
|
+
process.stdout.write(`${colorPatch(f.patch)}\n`);
|
|
75
|
+
for (const f of missing)
|
|
76
|
+
log.warn(`${f.file} is missing locally (the registry has it).`);
|
|
77
|
+
if (!changed.length && !missing.length) {
|
|
78
|
+
log.success(`${item.name} is up to date with the registry.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
log.blank();
|
|
82
|
+
log.info(`${item.name}: ${plural(changed.length, 'file')} differ${changed.length === 1 ? 's' : ''}` +
|
|
83
|
+
(missing.length ? `, ${missing.length} missing` : '') +
|
|
84
|
+
color.dim(` — \`uipkge-ng add ${ref} --overwrite\` takes the registry version.`));
|
|
85
|
+
}
|
|
86
|
+
// Skip the tokens/bundle items: `tailwind`'s declared target is the user's own
|
|
87
|
+
// stylesheet (init writes the tokens elsewhere), so comparing it is meaningless.
|
|
88
|
+
const NOT_DIFFABLE = new Set(['tailwind', 'init']);
|
|
89
|
+
/**
|
|
90
|
+
* Installed items from the default registry and every named one, as refs
|
|
91
|
+
* (`button`, `@acme/card`). A named registry that can't be read is reported
|
|
92
|
+
* and skipped rather than failing the whole check.
|
|
93
|
+
*/
|
|
94
|
+
export async function installedRefs(root, registry, namespaces, layout) {
|
|
95
|
+
const sources = [undefined, ...namespaces];
|
|
96
|
+
const refs = await Promise.all(sources.map(async (ns) => {
|
|
97
|
+
let items;
|
|
98
|
+
try {
|
|
99
|
+
items = (await registry.index(ns)).items;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (!ns)
|
|
103
|
+
throw error;
|
|
104
|
+
log.warn(`Skipped ${ns}: ${error instanceof Error ? error.message : String(error)}`);
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
return items
|
|
108
|
+
.filter(i => !NOT_DIFFABLE.has(i.name) && isItemInstalled(root, adaptItem(i, layout)))
|
|
109
|
+
.map(i => (ns ? `${ns}/${i.name}` : i.name));
|
|
110
|
+
}));
|
|
111
|
+
return refs.flat();
|
|
112
|
+
}
|
|
113
|
+
async function diffAll(root, registry, namespaces, layout) {
|
|
114
|
+
const installed = await installedRefs(root, registry, namespaces, layout);
|
|
115
|
+
if (!installed.length) {
|
|
116
|
+
log.info('No uipkge components are installed yet. Add one with `uipkge-ng add <name>`.');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
log.step(`Checking ${plural(installed.length, 'component')} against ${[registry.baseUrl, ...namespaces].join(', ')}`);
|
|
120
|
+
const outdated = [];
|
|
121
|
+
const queue = [...installed];
|
|
122
|
+
// A handful in parallel: fast, without hammering the registry.
|
|
123
|
+
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, async () => {
|
|
124
|
+
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
125
|
+
const files = await compareItem(root, adaptItem(await registry.item(next), layout));
|
|
126
|
+
if (files.some(f => f.status !== 'same'))
|
|
127
|
+
outdated.push(next);
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
if (!outdated.length) {
|
|
131
|
+
log.success(`All ${plural(installed.length, 'component')} match the registry.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
outdated.sort();
|
|
135
|
+
log.info(`${plural(outdated.length, 'component')} ${outdated.length === 1 ? 'differs' : 'differ'} from the registry:`);
|
|
136
|
+
for (const name of outdated)
|
|
137
|
+
log.info(` ${color.yellow('•')} ${name}`);
|
|
138
|
+
log.blank();
|
|
139
|
+
log.info(color.dim('See the changes with `uipkge-ng diff <name>`.'));
|
|
140
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { readConfig, registryFor } from '../config.js';
|
|
4
|
+
import { isItemInstalled } from '../files.js';
|
|
5
|
+
import { adaptItem, resolveLayout } from '../layout.js';
|
|
6
|
+
import { color } from '../output.js';
|
|
7
|
+
import { declaredDependencies, detectPackageManager, findApp, readPackageJson } from '../project.js';
|
|
8
|
+
import { FOUNDATION_ITEMS, resolveRegistryUrl } from '../registry.js';
|
|
9
|
+
import { POSTCSS_FILES } from '../setup.js';
|
|
10
|
+
/** A snapshot for bug reports: never throws on a broken project, it reports what it finds. */
|
|
11
|
+
export async function collectInfo(cwd, version) {
|
|
12
|
+
const location = findApp(path.resolve(cwd));
|
|
13
|
+
const root = location?.root ?? null;
|
|
14
|
+
const packageRoot = location?.packageRoot ?? null;
|
|
15
|
+
const pkg = packageRoot ? await readPackageJson(packageRoot).catch(() => null) : null;
|
|
16
|
+
const deps = pkg ? declaredDependencies(pkg) : {};
|
|
17
|
+
const config = root ? await readConfig(root).catch(() => null) : null;
|
|
18
|
+
const url = resolveRegistryUrl(config?.registryUrl);
|
|
19
|
+
const info = {
|
|
20
|
+
cli: version,
|
|
21
|
+
node: process.version,
|
|
22
|
+
platform: `${process.platform}-${process.arch}`,
|
|
23
|
+
project: {
|
|
24
|
+
root,
|
|
25
|
+
packageRoot,
|
|
26
|
+
app: location?.app?.name ?? null,
|
|
27
|
+
apps: location?.ambiguous?.map(a => a.name) ?? null,
|
|
28
|
+
angular: deps['@angular/core'] ?? null,
|
|
29
|
+
analog: Boolean(deps['@analogjs/platform'] || deps['@analogjs/vite-plugin-angular']),
|
|
30
|
+
nx: Boolean(deps['nx'] || (packageRoot && existsSync(path.join(packageRoot, 'nx.json')))),
|
|
31
|
+
packageManager: packageRoot && pkg ? detectPackageManager(packageRoot, pkg) : null,
|
|
32
|
+
tailwind: deps['tailwindcss'] ?? null,
|
|
33
|
+
postcssConfig: packageRoot ? (POSTCSS_FILES.find(f => existsSync(path.join(packageRoot, f))) ?? null) : null,
|
|
34
|
+
},
|
|
35
|
+
config,
|
|
36
|
+
layout: null,
|
|
37
|
+
registry: { url, reachable: false, items: null },
|
|
38
|
+
installed: [],
|
|
39
|
+
};
|
|
40
|
+
let layout;
|
|
41
|
+
if (root && config) {
|
|
42
|
+
try {
|
|
43
|
+
layout = resolveLayout(root, config.aliases);
|
|
44
|
+
info.layout = layout.dirs;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
info.layout = { error: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const index = await registryFor(root, config, cwd).index();
|
|
52
|
+
info.registry.reachable = true;
|
|
53
|
+
info.registry.items = index.items.length;
|
|
54
|
+
if (root && config) {
|
|
55
|
+
info.installed = index.items
|
|
56
|
+
.filter(i => !FOUNDATION_ITEMS.has(i.name) && isItemInstalled(root, layout ? adaptItem(i, layout) : i))
|
|
57
|
+
.map(i => i.name)
|
|
58
|
+
.sort();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
info.registry.error = error instanceof Error ? error.message : String(error);
|
|
63
|
+
}
|
|
64
|
+
// Named registries: best effort, an unreachable one just adds nothing.
|
|
65
|
+
if (root && config?.registries) {
|
|
66
|
+
const registry = registryFor(root, config, cwd);
|
|
67
|
+
for (const ns of Object.keys(config.registries)) {
|
|
68
|
+
try {
|
|
69
|
+
const items = (await registry.index(ns)).items;
|
|
70
|
+
info.installed.push(...items.filter(i => isItemInstalled(root, layout ? adaptItem(i, layout) : i)).map(i => `${ns}/${i.name}`));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// reported by `uipkge-ng list <ns>`
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return info;
|
|
78
|
+
}
|
|
79
|
+
export async function runInfo(options) {
|
|
80
|
+
const info = await collectInfo(options.cwd, options.version);
|
|
81
|
+
if (options.json) {
|
|
82
|
+
process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const yes = (v) => (v ? color.green(String(v)) : color.dim('no'));
|
|
86
|
+
const row = (label, value) => ` ${color.dim(label.padEnd(16))}${value}`;
|
|
87
|
+
const out = [
|
|
88
|
+
color.bold('uipkge'),
|
|
89
|
+
row('cli', info.cli),
|
|
90
|
+
row('node', `${info.node} (${info.platform})`),
|
|
91
|
+
'',
|
|
92
|
+
color.bold('Project'),
|
|
93
|
+
row('root', info.project.root ?? color.dim('no package.json found')),
|
|
94
|
+
...(info.project.packageRoot && info.project.packageRoot !== info.project.root ? [row('workspace', info.project.packageRoot)] : []),
|
|
95
|
+
...(info.project.app ? [row('app', info.project.app)] : []),
|
|
96
|
+
...(info.project.apps ? [row('apps', color.yellow(`${info.project.apps.join(', ')} — run inside one`))] : []),
|
|
97
|
+
row('angular', info.project.angular ?? color.yellow('not an Angular project')),
|
|
98
|
+
row('package manager', info.project.packageManager ?? '—'),
|
|
99
|
+
row('tailwind', yes(info.project.tailwind)),
|
|
100
|
+
row('postcss config', yes(info.project.postcssConfig)),
|
|
101
|
+
row('analog / nx', `${info.project.analog ? 'analog' : 'no'} / ${info.project.nx ? color.yellow('nx (not supported yet)') : 'no'}`),
|
|
102
|
+
'',
|
|
103
|
+
color.bold('components.json'),
|
|
104
|
+
info.config ? JSON.stringify(info.config, null, 2).split('\n').map(l => ` ${l}`).join('\n') : row('status', color.yellow('not initialized — run `uipkge-ng init`')),
|
|
105
|
+
...(info.layout
|
|
106
|
+
? [
|
|
107
|
+
'',
|
|
108
|
+
color.bold('Folders'),
|
|
109
|
+
...('error' in info.layout
|
|
110
|
+
? [row('aliases', color.red(info.layout.error))]
|
|
111
|
+
: [row('ui', info.layout.ui), row('lib', info.layout.lib), row('blocks', info.layout.blocks)]),
|
|
112
|
+
]
|
|
113
|
+
: []),
|
|
114
|
+
'',
|
|
115
|
+
color.bold('Registry'),
|
|
116
|
+
row('url', info.registry.url),
|
|
117
|
+
row('reachable', info.registry.reachable ? color.green(`yes (${info.registry.items} items)`) : color.red(`no — ${info.registry.error}`)),
|
|
118
|
+
row('installed', info.config ? (info.installed.length ? `${info.installed.length}: ${info.installed.join(', ')}` : 'none yet') : '—'),
|
|
119
|
+
row('named', Object.keys(info.config?.registries ?? {}).join(', ') || color.dim('none')),
|
|
120
|
+
];
|
|
121
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
122
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { defaultConfig, readConfig, registryFor, writeConfig } from '../config.js';
|
|
5
|
+
import { UipkgeError } from '../errors.js';
|
|
6
|
+
import { writeItemFiles } from '../files.js';
|
|
7
|
+
import { adaptItem, resolveLayout } from '../layout.js';
|
|
8
|
+
import { color, log } from '../output.js';
|
|
9
|
+
import { installPackages, missingPackages } from '../packages.js';
|
|
10
|
+
import { declaredDependencies, loadProject } from '../project.js';
|
|
11
|
+
import { confirm, isInteractive } from '../prompts.js';
|
|
12
|
+
import { createRegistry, DEFAULT_REGISTRY_URL, NotFoundError, resolveRegistryUrl } from '../registry.js';
|
|
13
|
+
import { addGlobalStyle, appTsconfigEdit, planTailwind, readPostcssConfig, relativeImport, withTailwindPlugin, tsconfigsToEdit, withTokensImport, writeIfChanged, } from '../setup.js';
|
|
14
|
+
export async function runInit(options) {
|
|
15
|
+
const project = await loadProject(options.cwd);
|
|
16
|
+
const { root, packageRoot } = project;
|
|
17
|
+
const existing = await readConfig(root);
|
|
18
|
+
if (existing && !options.overwrite) {
|
|
19
|
+
log.info(`uipkge is already set up in ${root} (components.json exists).`);
|
|
20
|
+
log.info(color.dim('Run `uipkge-ng init --overwrite` to redo it, or `uipkge-ng add <name>` to add components.'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!options.yes && isInteractive() && !(await confirm(`Set up uipkge in ${root}?`))) {
|
|
24
|
+
log.info('Nothing was changed.');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Keep a registry chosen in an existing components.json when re-running.
|
|
28
|
+
const config = existing ?? { ...defaultConfig(resolveRegistryUrl()), ...stylesFor(project.app) };
|
|
29
|
+
const registry = registryFor(root, config);
|
|
30
|
+
// Everything that can fail on the network happens before the project is touched.
|
|
31
|
+
log.step(`Fetching foundation from ${registry.baseUrl}`);
|
|
32
|
+
// A registry that ships only its own components can still be the default: the foundation then comes from uipkge.
|
|
33
|
+
const foundation = async (name) => {
|
|
34
|
+
try {
|
|
35
|
+
return await registry.item(name);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (!(error instanceof NotFoundError) || registry.baseUrl === DEFAULT_REGISTRY_URL)
|
|
39
|
+
throw error;
|
|
40
|
+
log.info(color.dim(` ${registry.baseUrl} has no \`${name}\`; using uipkge's (${DEFAULT_REGISTRY_URL}).`));
|
|
41
|
+
return createRegistry(DEFAULT_REGISTRY_URL).item(name);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const [utils, tokens] = await Promise.all([foundation('utils'), foundation('tailwind')]);
|
|
45
|
+
const tokensCss = tokens.files.find(f => f.target?.endsWith('.css') || f.path?.endsWith('.css'))?.content;
|
|
46
|
+
if (!tokensCss)
|
|
47
|
+
throw new UipkgeError('The registry `tailwind` item has no CSS file.');
|
|
48
|
+
const done = [];
|
|
49
|
+
const declared = declaredDependencies(project.packageJson);
|
|
50
|
+
// 1. npm packages: Tailwind (dev) + what utils/tokens import.
|
|
51
|
+
// PostCSS config is read from the workspace root, where the Angular CLI runs.
|
|
52
|
+
const tailwind = planTailwind({ ...project, postcss: await readPostcssConfig(packageRoot) });
|
|
53
|
+
const devPackages = tailwind.kind === 'skip' ? [] : missingPackages(tailwind.devPackages, declared);
|
|
54
|
+
const packages = missingPackages([...(utils.dependencies ?? []), ...(tokens.dependencies ?? [])], declared);
|
|
55
|
+
if (packages.length || devPackages.length) {
|
|
56
|
+
log.step(`Installing ${[...packages, ...devPackages].join(', ')}`);
|
|
57
|
+
await installPackages(project.packageManager, packageRoot, packages);
|
|
58
|
+
await installPackages(project.packageManager, packageRoot, devPackages, true);
|
|
59
|
+
}
|
|
60
|
+
// 2. Tailwind wiring.
|
|
61
|
+
if (tailwind.kind === 'postcss') {
|
|
62
|
+
const file = path.join(packageRoot, tailwind.config.file);
|
|
63
|
+
if (tailwind.config.action === 'create')
|
|
64
|
+
await writeFile(file, withTailwindPlugin(''), 'utf8');
|
|
65
|
+
if (tailwind.config.action === 'merge')
|
|
66
|
+
await writeFile(file, withTailwindPlugin(await readFile(file, 'utf8')), 'utf8');
|
|
67
|
+
if (tailwind.config.action === 'manual') {
|
|
68
|
+
log.warn(`Add "@tailwindcss/postcss" to the plugins in ${tailwind.config.file} so Tailwind runs.`);
|
|
69
|
+
}
|
|
70
|
+
done.push('Tailwind CSS v4 set up');
|
|
71
|
+
}
|
|
72
|
+
else if (tailwind.kind === 'vite') {
|
|
73
|
+
log.warn("Analog/Vite: add Tailwind to vite.config.ts — `import tailwindcss from '@tailwindcss/vite'` and `plugins: [tailwindcss()]`.");
|
|
74
|
+
done.push('Tailwind CSS v4 installed (add the Vite plugin)');
|
|
75
|
+
}
|
|
76
|
+
// 3. tsconfig aliases the component sources import.
|
|
77
|
+
const tsconfigs = tsconfigsToEdit(root);
|
|
78
|
+
if (!tsconfigs.length)
|
|
79
|
+
throw new UipkgeError(`No tsconfig.json in ${root}.`);
|
|
80
|
+
const edited = [];
|
|
81
|
+
for (const file of tsconfigs) {
|
|
82
|
+
const abs = path.join(root, file);
|
|
83
|
+
if (await writeIfChanged(abs, appTsconfigEdit(root, file, await readFile(abs, 'utf8'))))
|
|
84
|
+
edited.push(file);
|
|
85
|
+
}
|
|
86
|
+
if (edited.length)
|
|
87
|
+
done.push(`tsconfig paths \`@/*\` and \`@/ui/*\` added (${edited.join(', ')})`);
|
|
88
|
+
// 4. cn() helper, wherever the `lib` alias points.
|
|
89
|
+
const layout = resolveLayout(root, config.aliases);
|
|
90
|
+
const utilsResult = await writeItemFiles(root, adaptItem(utils, layout), options.overwrite);
|
|
91
|
+
if (utilsResult.written.length)
|
|
92
|
+
done.push(`cn() helper written (${utilsResult.written.join(', ')})`);
|
|
93
|
+
// A kept file of the user's own is fine as long as it still gives components their cn().
|
|
94
|
+
for (const file of utilsResult.kept) {
|
|
95
|
+
if (!/export\s+(?:function|const|let)\s+cn\b|export\s*\{[^}]*\bcn\b[^}]*\}/.test(await readFile(path.join(root, file), 'utf8'))) {
|
|
96
|
+
log.warn(`${file} already exists and doesn't export cn(); components import it from there. Add it (\`uipkge-ng view utils\`) or re-run with --overwrite.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 5. Design tokens in a file uipkge owns; the user's stylesheet only gains an import.
|
|
100
|
+
const tokensFile = path.join(root, config.tokens);
|
|
101
|
+
if (!existsSync(tokensFile) || options.overwrite) {
|
|
102
|
+
await mkdir(path.dirname(tokensFile), { recursive: true });
|
|
103
|
+
await writeFile(tokensFile, tokensCss, 'utf8');
|
|
104
|
+
done.push(`design tokens written (${config.tokens})`);
|
|
105
|
+
}
|
|
106
|
+
await linkTokens(project, config.styles, config.tokens, done);
|
|
107
|
+
await writeConfig(root, config);
|
|
108
|
+
done.push('components.json written');
|
|
109
|
+
log.blank();
|
|
110
|
+
log.success(color.bold('uipkge is ready.'));
|
|
111
|
+
for (const line of done)
|
|
112
|
+
log.info(` ${color.green('✔')} ${line}`);
|
|
113
|
+
log.blank();
|
|
114
|
+
log.info(`Add a component: ${color.cyan('uipkge-ng add button')}`);
|
|
115
|
+
log.info(`Browse them all: ${color.cyan('uipkge-ng list')}`);
|
|
116
|
+
}
|
|
117
|
+
const TOKENS_FILE = 'uipkge.css';
|
|
118
|
+
/** Global stylesheet and tokens file for a new components.json, from the app's angular.json styles. */
|
|
119
|
+
function stylesFor(app) {
|
|
120
|
+
// Skip a tokens file an earlier init registered, or it would import itself.
|
|
121
|
+
const candidates = app?.styles.filter(f => path.posix.basename(f) !== TOKENS_FILE) ?? [];
|
|
122
|
+
const styles = candidates.find(f => f.endsWith('.css')) ?? candidates[0];
|
|
123
|
+
if (!styles || styles.startsWith('..'))
|
|
124
|
+
return undefined;
|
|
125
|
+
return { styles, tokens: path.posix.join(path.posix.dirname(styles), TOKENS_FILE) };
|
|
126
|
+
}
|
|
127
|
+
async function linkTokens(project, styles, tokens, done) {
|
|
128
|
+
const { root } = project;
|
|
129
|
+
const stylesFile = path.join(root, styles);
|
|
130
|
+
const importPath = relativeImport(styles, tokens);
|
|
131
|
+
if (path.resolve(root, styles) === path.resolve(root, tokens)) {
|
|
132
|
+
throw new UipkgeError(`"styles" and "tokens" in components.json are the same file (${styles}).`, 'Point "styles" at your global stylesheet.');
|
|
133
|
+
}
|
|
134
|
+
// Sass/Less: a CSS import there doesn't carry Tailwind v4's syntax reliably, so the tokens become their own global stylesheet.
|
|
135
|
+
if (!styles.endsWith('.css') && project.app) {
|
|
136
|
+
const entry = path.relative(project.packageRoot, path.join(root, tokens)).replace(/\\/g, '/');
|
|
137
|
+
if (await addGlobalStyle(project.packageRoot, project.app.name, entry))
|
|
138
|
+
done.push(`angular.json styles include ${entry}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!existsSync(stylesFile)) {
|
|
142
|
+
const preprocessed = ['.scss', '.sass', '.less'].map(ext => styles.replace(/\.css$/, ext)).find(f => existsSync(path.join(root, f)));
|
|
143
|
+
if (preprocessed) {
|
|
144
|
+
log.warn(`Your global stylesheet is ${preprocessed}. Add \`@import '${importPath}';\` to a CSS file listed in angular.json "styles".`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await mkdir(path.dirname(stylesFile), { recursive: true });
|
|
148
|
+
await writeFile(stylesFile, '', 'utf8');
|
|
149
|
+
log.warn(`Created ${styles}; make sure it is listed under "styles" in angular.json.`);
|
|
150
|
+
}
|
|
151
|
+
if (await writeIfChanged(stylesFile, withTokensImport(await readFile(stylesFile, 'utf8'), importPath))) {
|
|
152
|
+
done.push(`${styles} imports ${importPath}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { readConfig, registryFor } from '../config.js';
|
|
3
|
+
import { UipkgeError } from '../errors.js';
|
|
4
|
+
import { isItemInstalled } from '../files.js';
|
|
5
|
+
import { adaptItem, resolveLayout } from '../layout.js';
|
|
6
|
+
import { color, plural } from '../output.js';
|
|
7
|
+
import { findAppRoot } from '../project.js';
|
|
8
|
+
import { FOUNDATION_ITEMS } from '../registry.js';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
/** Foundation items are init's job; the style item is not something you add. */
|
|
11
|
+
export function isListable(item) {
|
|
12
|
+
return !FOUNDATION_ITEMS.has(item.name) && item.type !== 'registry:style';
|
|
13
|
+
}
|
|
14
|
+
export function firstSentence(text) {
|
|
15
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
16
|
+
const end = flat.search(/[.!?](\s|$)/);
|
|
17
|
+
return end === -1 ? flat : flat.slice(0, end);
|
|
18
|
+
}
|
|
19
|
+
const OTHER = 'other';
|
|
20
|
+
/** Filters, then groups by first category (alphabetical, `other` last), rows sorted by name. */
|
|
21
|
+
export function groupRows(items, filter) {
|
|
22
|
+
const query = filter.query?.trim().toLowerCase();
|
|
23
|
+
const category = filter.category?.trim().toLowerCase();
|
|
24
|
+
const rows = items.filter(isListable).flatMap((item) => {
|
|
25
|
+
if (category && !(item.categories ?? []).some(c => c.toLowerCase() === category))
|
|
26
|
+
return [];
|
|
27
|
+
if (query && ![item.name, item.title, item.description].some(v => v?.toLowerCase().includes(query)))
|
|
28
|
+
return [];
|
|
29
|
+
const installed = filter.installed(item);
|
|
30
|
+
if (filter.installedOnly && !installed)
|
|
31
|
+
return [];
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
name: item.name,
|
|
35
|
+
title: item.title ?? item.name,
|
|
36
|
+
description: firstSentence(item.description ?? ''),
|
|
37
|
+
category: (item.categories?.[0] ?? OTHER).toLowerCase(),
|
|
38
|
+
installed,
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
});
|
|
42
|
+
const byCategory = new Map();
|
|
43
|
+
for (const row of rows)
|
|
44
|
+
byCategory.set(row.category, [...(byCategory.get(row.category) ?? []), row]);
|
|
45
|
+
return [...byCategory]
|
|
46
|
+
.sort(([a], [b]) => (a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b)))
|
|
47
|
+
.map(([cat, list]) => ({ category: cat, rows: list.sort((x, y) => x.name.localeCompare(y.name)) }));
|
|
48
|
+
}
|
|
49
|
+
export const ellipsize = (text, width) => width < 2 ? '' : text.length <= width ? text : `${text.slice(0, width - 1).trimEnd()}…`;
|
|
50
|
+
export async function runList(options) {
|
|
51
|
+
const cwd = path.resolve(options.cwd);
|
|
52
|
+
if (!existsSync(cwd))
|
|
53
|
+
throw new UipkgeError(`The directory ${cwd} does not exist.`);
|
|
54
|
+
// Works anywhere (browse before init); installed marks need an initialized project.
|
|
55
|
+
const root = findAppRoot(cwd);
|
|
56
|
+
const config = root ? await readConfig(root) : null;
|
|
57
|
+
const registry = registryFor(root, config, cwd);
|
|
58
|
+
const index = await registry.index(options.namespace);
|
|
59
|
+
// A broken alias setup shouldn't stop browsing; installed marks fall back to the default folders.
|
|
60
|
+
let layout;
|
|
61
|
+
if (config && root) {
|
|
62
|
+
try {
|
|
63
|
+
layout = resolveLayout(root, config.aliases);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
layout = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const groups = groupRows(options.namespace ? index.items.map(i => ({ ...i, name: `${options.namespace}/${i.name}` })) : index.items, {
|
|
70
|
+
query: options.query,
|
|
71
|
+
category: options.category,
|
|
72
|
+
installedOnly: options.installedOnly,
|
|
73
|
+
installed: item => Boolean(config && root && isItemInstalled(root, layout ? adaptItem(item, layout) : item)),
|
|
74
|
+
});
|
|
75
|
+
const rows = groups.flatMap(g => g.rows);
|
|
76
|
+
if (options.json) {
|
|
77
|
+
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!rows.length) {
|
|
81
|
+
const what = options.installedOnly ? 'No installed components' : 'No components';
|
|
82
|
+
process.stdout.write(`${what}${options.query ? ` match "${options.query}"` : ''}.\n`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const width = Math.min(Math.max(process.stdout.columns || 100, 60), 120);
|
|
86
|
+
const nameWidth = Math.min(Math.max(...rows.map(r => r.name.length)) + 2, 30);
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const group of groups) {
|
|
89
|
+
out.push('', `${color.bold(group.category)} ${color.dim(`(${group.rows.length})`)}`);
|
|
90
|
+
for (const row of group.rows) {
|
|
91
|
+
const mark = row.installed ? color.green('✔') : ' ';
|
|
92
|
+
out.push(` ${mark} ${row.name.padEnd(nameWidth)}${color.dim(ellipsize(row.description, width - nameWidth - 4))}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const installedCount = rows.filter(r => r.installed).length;
|
|
96
|
+
out.push('', color.dim(plural(rows.length, 'component') +
|
|
97
|
+
(config ? ` · ${installedCount} installed` : '') +
|
|
98
|
+
(options.query ? ` matching "${options.query}"` : '')));
|
|
99
|
+
if (!config)
|
|
100
|
+
out.push(color.dim('Run `uipkge-ng init` in your project to see which ones are installed.'));
|
|
101
|
+
out.push(color.dim('Add one with `uipkge-ng add <name>`.'), '');
|
|
102
|
+
process.stdout.write(out.join('\n'));
|
|
103
|
+
}
|