uipkge-ng 0.1.1 → 0.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to `uipkge-ng` (and its aliases `@uipkge/ng` and `uipkge`). This project follows [Semantic Versioning](https://semver.org); while it's 0.x, minor versions may change behaviour.
4
4
 
5
+ ## 0.1.2 — 2026-09-26
6
+
7
+ ### Added
8
+
9
+ - Nx workspaces: Angular apps are found from their `project.json` (Node apps and libraries are skipped), `@/*` and `@/ui/*` go into `tsconfig.base.json` beside your library paths, and a Sass app gets the tokens in its `project.json`. Works from the workspace root or inside an app.
10
+ - Components added from a URL or a local file are recorded in `components.json` under `sources`, so `list`, `diff`, `info` and `doctor` include them like registry components.
11
+ - `doctor` and `init` warn about a global `* { margin: 0; padding: 0 }` outside a CSS layer — it overrides Tailwind v4's layered utilities and strips every component's spacing — and tell you to move it into `@layer base`.
12
+
13
+ ### Fixed
14
+
15
+ - A project with `@analogjs/vite-plugin-angular` (used for Vitest in Nx and some Angular CLI setups) is no longer mistaken for an Analog app, which pointed Tailwind setup at Vite instead of PostCSS.
16
+
5
17
  ## 0.1.1 — 2026-09-26
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -41,7 +41,7 @@ import { UiButtonComponent } from '@/ui/button';
41
41
  ## Requirements
42
42
 
43
43
  - Node.js **20.12** or newer
44
- - An Angular CLI application with standalone components (Analog/Vite supported; Nx workspaces not yet)
44
+ - An Angular CLI application with standalone components — including multi-project workspaces and Nx (Analog/Vite supported)
45
45
  - npm, pnpm, yarn or bun — detected from your lockfile
46
46
 
47
47
  ## Getting started
@@ -191,7 +191,7 @@ Components
191
191
  → Run `npm install class-variance-authority`.
192
192
  ```
193
193
 
194
- It checks Node, the Angular app (and workspace), `components.json`, that the registry and every named registry answer (and their tokens are set), that your aliases resolve, Tailwind v4 and its build step, that the design tokens are actually loaded, the `cn()` helper, components with missing files, and npm packages your components need but `package.json` doesn't list. It changes nothing. It exits with 1 when something fails, so it can run in CI; `--json` for scripts.
194
+ It checks Node, the Angular app (and workspace), `components.json`, that the registry and every named registry answer (and their tokens are set), that your aliases resolve, Tailwind v4 and its build step, that the design tokens are actually loaded, the `cn()` helper, global CSS resets that would override Tailwind (`* { padding: 0 }` outside a layer), components with missing files, and npm packages your components need but `package.json` doesn't list. It changes nothing. It exits with 1 when something fails, so it can run in CI; `--json` for scripts.
195
195
 
196
196
  ## Existing projects
197
197
 
@@ -199,6 +199,7 @@ uipkge-ng is built to be run on apps that already have code in them:
199
199
 
200
200
  - **Your files are never overwritten silently.** If a file `add` would write already exists with different content — say your own `button.component.ts` — it stops before writing anything. Interactively it asks; otherwise re-run with `--overwrite`, or install elsewhere with `--path src/app/uipkge`.
201
201
  - **Your styles stay yours.** Tokens live in their own file (`src/uipkge.css`); your stylesheet only gains one import line.
202
+ - **Your global resets are checked.** Tailwind v4 keeps its utilities in a CSS layer, and CSS outside a layer always wins — so a `* { margin: 0; padding: 0 }` reset (common in older apps and in some starters) strips every component's spacing. `init` and `doctor` point it out; wrap it in `@layer base { … }` and everything spaces correctly again.
202
203
  - **Your tsconfig stays yours.** Existing paths are kept. If `@/*` already points somewhere else, uipkge's path is added as a fallback and the helper goes where your mapping resolves.
203
204
  - **Your `utils.ts` stays yours.** If `src/lib/utils.ts` exists, `init` keeps it and warns if it doesn't export `cn()`.
204
205
  - **Try first.** `add --dry-run` shows exactly what would change, `add --diff` shows how.
@@ -216,7 +217,7 @@ Each app gets its own `components.json`, tokens and paths — written to its `ts
216
217
 
217
218
  **npm, pnpm, yarn and bun workspaces**: run it in the app (`apps/web`). The package manager comes from the workspace's lockfile.
218
219
 
219
- **Nx** isn't supported yet.
220
+ **Nx** workspaces work from the workspace root or inside an app (`apps/shop`). Angular apps are found from their `project.json` — Node apps and libraries are skipped — and `@/*` / `@/ui/*` go into `tsconfig.base.json` next to your library paths, where Nx keeps them. A Sass app gets the tokens in its `project.json` `styles`.
220
221
 
221
222
  ## components.json
222
223
 
@@ -244,6 +245,7 @@ Each app gets its own `components.json`, tokens and paths — written to its `ts
244
245
  | `tokens` | the file uipkge owns for design tokens and item CSS |
245
246
  | `aliases` | where components, helpers and blocks go — see [Custom folders](#custom-folders) |
246
247
  | `registries` | named and private registries — see [Registries](#registries) |
248
+ | `sources` | written by `add`: where components added from a URL or a local file came from, so `list`, `diff`, `info` and `doctor` include them |
247
249
 
248
250
  ## Custom folders
249
251
 
@@ -300,7 +302,7 @@ uipkge-ng view @internal/data-table
300
302
  - `{name}` becomes the item name; `list` reads the index at `{name}` = `registry`.
301
303
  - `${VAR}` in headers comes from the environment, then `.env.local`, then `.env`. A missing variable is an error before any request is made.
302
304
  - Headers are only ever sent to that registry's own URLs.
303
- - `list @name`, `info` and `diff` show which of its items you have. Items added from a URL or a file can be checked with `uipkge-ng diff <url-or-file>`.
305
+ - `list @name`, `info` and `diff` show which of its items you have. Components added from a URL or a local file are recorded under `sources`, so `list` (in their own group), `diff`, `info` and `doctor` include them too.
304
306
 
305
307
  ## Publishing your own registry
306
308
 
@@ -374,6 +376,12 @@ It never writes files — installing stays a command you run. It reads the `comp
374
376
  | `--debug` | stack traces on errors |
375
377
  | `-h, --help` / `-v, --version` | |
376
378
 
379
+ ## Known limits
380
+
381
+ - **Tested by hand on macOS.** CI runs Linux, macOS and Windows on every push (Windows doesn't block yet).
382
+ - **Analog** apps build with Vite: `init` installs `@tailwindcss/vite`, but you add `tailwindcss()` to `vite.config.ts` yourself.
383
+ - `--path` moves only the components you name; their dependencies stay in the default folder so imports keep resolving.
384
+
377
385
  ## Troubleshooting
378
386
 
379
387
  | Message | Fix |
@@ -383,7 +391,6 @@ It never writes files — installing stays a command you run. It reads the `comp
383
391
  | `N files already exist with different content` | keep yours and use `--path`, or replace with `--overwrite` |
384
392
  | `The "ui" alias "…" is not mapped in tsconfig.json "paths"` | add the mapping, or reset `aliases` to the defaults |
385
393
  | `Registry "@acme" needs the environment variable ACME_TOKEN` | set it in your shell, `.env.local` or `.env` |
386
- | `Nx workspaces are not supported yet` | use a standalone Angular CLI app for now |
387
394
 
388
395
  Start with `npx uipkge-ng doctor` — it finds most setup problems and tells you the fix. Still stuck? Include the output of `npx uipkge-ng info` when you [open an issue](https://github.com/uday-a/uipkge-cli/issues).
389
396
 
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { registryFor, requireConfig } from '../config.js';
3
+ import { registryFor, requireConfig, toSource, writeConfig } from '../config.js';
4
4
  import { UipkgeError } from '../errors.js';
5
5
  import { applyCss, applyEnv, envKeys, hasCss } from '../extras.js';
6
6
  import { assertWritable, conflictingFiles, isItemInstalled, targetedFiles, writeItemFiles } from '../files.js';
@@ -9,6 +9,7 @@ import { color, log, plural } from '../output.js';
9
9
  import { installPackages, missingPackages, pinAngular } from '../packages.js';
10
10
  import { declaredDependencies, loadProject, resolveTarget } from '../project.js';
11
11
  import { confirm, isInteractive, pickComponents } from '../prompts.js';
12
+ import { refKind } from '../registry.js';
12
13
  import { planInstall } from '../resolve.js';
13
14
  import { colorPatch, compareItem } from './diff.js';
14
15
  import { firstSentence, isListable } from './list.js';
@@ -66,7 +67,23 @@ export async function runAdd(options) {
66
67
  return printDiff(root, previewPlan, options.diff);
67
68
  if (options.dryRun)
68
69
  return printDryRun(root, config.tokens, plan, packages, devPackages, options.overwrite);
70
+ /** Remember where URL/file components came from, so diff/info/doctor can find them later. */
71
+ const recordSources = async () => {
72
+ const cwd = path.resolve(options.cwd);
73
+ let changed = false;
74
+ for (const ref of names.filter(n => refKind(n) === 'url' || refKind(n) === 'file')) {
75
+ const { name } = await registry.item(ref);
76
+ const source = toSource(root, cwd, ref);
77
+ if (config.sources?.[name] !== source) {
78
+ config.sources = { ...config.sources, [name]: source };
79
+ changed = true;
80
+ }
81
+ }
82
+ if (changed)
83
+ await writeConfig(root, config);
84
+ };
69
85
  if (!plan.install.length) {
86
+ await recordSources();
70
87
  log.info(`Already installed: ${plan.skipped.join(', ')}. ${color.dim('Use --overwrite to reinstall.')}`);
71
88
  return;
72
89
  }
@@ -121,6 +138,7 @@ export async function runAdd(options) {
121
138
  const summary = targetedFiles(item).length ? plural(result.written.length, 'file') : 'bundle';
122
139
  log.success(`${item.name} ${color.dim(`(${[summary, ...extras].join(', ')})`)}`);
123
140
  }
141
+ await recordSources();
124
142
  log.blank();
125
143
  log.info(`${color.green('✔')} Added ${plural(plan.install.length, 'component')}.`);
126
144
  if (plan.skipped.length)
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { createTwoFilesPatch } from 'diff';
5
- import { registryFor, requireConfig } from '../config.js';
5
+ import { fromSource, registryFor, requireConfig } from '../config.js';
6
6
  import { isItemInstalled, targetedFiles, normalizeEol } from '../files.js';
7
7
  import { adaptItem, resolveLayout } from '../layout.js';
8
8
  import { color, log, plural } from '../output.js';
@@ -59,7 +59,7 @@ export async function runDiff(options) {
59
59
  await diffOne(root, adaptItem(await registry.item(options.name), layout), options.name);
60
60
  return;
61
61
  }
62
- await diffAll(root, registry, Object.keys(config.registries ?? {}), layout);
62
+ await diffAll(root, registry, Object.keys(config.registries ?? {}), layout, config.sources ?? {});
63
63
  }
64
64
  async function diffOne(root, item, ref) {
65
65
  const files = await compareItem(root, item);
@@ -110,8 +110,29 @@ export async function installedRefs(root, registry, namespaces, layout) {
110
110
  }));
111
111
  return refs.flat();
112
112
  }
113
- async function diffAll(root, registry, namespaces, layout) {
114
- const installed = await installedRefs(root, registry, namespaces, layout);
113
+ /**
114
+ * Components added from a URL or file (components.json `sources`) that are still installed,
115
+ * as loadable refs mapped to how they're shown (the stored source). A source that can't be
116
+ * loaded any more is reported and skipped.
117
+ */
118
+ export async function installedSources(root, registry, sources, layout) {
119
+ const found = new Map();
120
+ for (const source of Object.values(sources)) {
121
+ const ref = fromSource(root, source);
122
+ try {
123
+ if (isItemInstalled(root, adaptItem(await registry.item(ref), layout)))
124
+ found.set(ref, source);
125
+ }
126
+ catch (error) {
127
+ log.warn(`Skipped ${source}: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }
130
+ return found;
131
+ }
132
+ async function diffAll(root, registry, namespaces, layout, sources) {
133
+ const external = await installedSources(root, registry, sources, layout);
134
+ const installed = [...(await installedRefs(root, registry, namespaces, layout)), ...external.keys()];
135
+ const label = (ref) => external.get(ref) ?? ref;
115
136
  if (!installed.length) {
116
137
  log.info('No uipkge components are installed yet. Add one with `uipkge-ng add <name>`.');
117
138
  return;
@@ -133,8 +154,8 @@ async function diffAll(root, registry, namespaces, layout) {
133
154
  }
134
155
  outdated.sort();
135
156
  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}`);
157
+ for (const ref of outdated)
158
+ log.info(` ${color.yellow('•')} ${label(ref)}`);
138
159
  log.blank();
139
160
  log.info(color.dim('See the changes with `uipkge-ng diff <name>`.'));
140
161
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { readConfig, registryFor } from '../config.js';
3
+ import { fromSource, readConfig, registryFor } from '../config.js';
4
4
  import { targetedFiles } from '../files.js';
5
5
  import { adaptItem, resolveLayout } from '../layout.js';
6
6
  import { color } from '../output.js';
@@ -8,7 +8,7 @@ import { installArgs, missingPackages } from '../packages.js';
8
8
  import { angularApps, declaredDependencies, detectPackageManager, findApp, readPackageJson, resolveTarget } from '../project.js';
9
9
  import { FOUNDATION_ITEMS, namedRegistryFrom } from '../registry.js';
10
10
  import { projectEnv } from '../env.js';
11
- import { TAILWIND_POSTCSS, readPostcssConfig, relativeImport } from '../setup.js';
11
+ import { TAILWIND_POSTCSS, readPostcssConfig, relativeImport, unlayeredResets } from '../setup.js';
12
12
  const MIN_NODE = [20, 12];
13
13
  /** `3: a, b, c` or `202: a, b, c, d, e, f … and 196 more`. */
14
14
  const summarize = (names, shown = 6) => `${names.length}: ${names.slice(0, shown).join(', ')}${names.length > shown ? ` … and ${names.length - shown} more` : ''}`;
@@ -66,9 +66,8 @@ export async function runChecks(cwd) {
66
66
  });
67
67
  if (!angular)
68
68
  return checks;
69
- const nx = Boolean(deps['nx'] || existsSync(path.join(packageRoot, 'nx.json')));
70
- if (nx)
71
- push({ section: 'Project', title: 'Nx', status: 'fail', detail: 'Nx workspaces are not supported yet' });
69
+ if (existsSync(path.join(packageRoot, 'nx.json')))
70
+ push({ section: 'Project', title: 'Workspace', status: 'pass', detail: 'Nx' });
72
71
  const pm = detectPackageManager(packageRoot, pkg);
73
72
  push({ section: 'Project', title: 'Package manager', status: 'pass', detail: pm });
74
73
  // ── Configuration ──
@@ -135,7 +134,7 @@ export async function runChecks(cwd) {
135
134
  detail: tailwind ?? 'not installed',
136
135
  fix: tailwindMajor >= 4 ? undefined : tailwind ? 'uipkge components need Tailwind CSS v4.' : 'Run `uipkge-ng init`.',
137
136
  });
138
- const analog = Boolean(deps['@analogjs/platform'] || deps['@analogjs/vite-plugin-angular']);
137
+ const analog = Boolean(deps['@analogjs/platform']);
139
138
  if (analog) {
140
139
  push({ section: 'Styling', title: 'Tailwind build', status: 'warn', detail: 'Analog builds with Vite', fix: "Check vite.config.ts has `tailwindcss()` from '@tailwindcss/vite' in plugins." });
141
140
  }
@@ -169,6 +168,19 @@ export async function runChecks(cwd) {
169
168
  fix: imported || registered ? undefined : `Add \`@import '${importPath}';\` to ${config.styles}, or list ${config.tokens} under "styles" in angular.json.`,
170
169
  });
171
170
  }
171
+ const globalStyles = path.join(root, config.styles);
172
+ if (existsSync(globalStyles)) {
173
+ const resets = unlayeredResets(readFileSync(globalStyles, 'utf8'));
174
+ push({
175
+ section: 'Styling',
176
+ title: 'Global resets',
177
+ status: resets.length ? 'warn' : 'pass',
178
+ detail: resets.length
179
+ ? `${config.styles} zeroes margin/padding on \`${resets[0]}\` outside a CSS layer, which overrides Tailwind's utilities (components lose their spacing)`
180
+ : 'none that override Tailwind',
181
+ fix: resets.length ? `Move that rule into \`@layer base { … }\` in ${config.styles}.` : undefined,
182
+ });
183
+ }
172
184
  if (layout) {
173
185
  const utils = path.join(root, layout.dirs.lib, 'utils.ts');
174
186
  const hasCn = existsSync(utils) && /export\s+(?:function|const|let)\s+cn\b|export\s*\{[^}]*\bcn\b[^}]*\}/.test(readFileSync(utils, 'utf8'));
@@ -185,9 +197,25 @@ export async function runChecks(cwd) {
185
197
  push({ section: 'Components', title: 'Installed components', status: 'skip', detail: 'needs the registry and folders above' });
186
198
  return checks;
187
199
  }
200
+ // Registry components, plus those added from a URL or file (recorded in `sources`).
201
+ const candidates = index.filter(i => !FOUNDATION_ITEMS.has(i.name));
202
+ for (const [name, source] of Object.entries(config.sources ?? {})) {
203
+ try {
204
+ candidates.push(await registry.item(fromSource(root, source)));
205
+ }
206
+ catch (error) {
207
+ push({
208
+ section: 'Components',
209
+ title: `Source of ${name}`,
210
+ status: 'warn',
211
+ detail: `${source}: ${error.message}`,
212
+ fix: `Fix or remove "${name}" under "sources" in components.json.`,
213
+ });
214
+ }
215
+ }
188
216
  const installed = [];
189
217
  const partial = [];
190
- for (const item of index.filter(i => !FOUNDATION_ITEMS.has(i.name))) {
218
+ for (const item of candidates) {
191
219
  const files = targetedFiles(adaptItem(item, layout));
192
220
  if (!files.length)
193
221
  continue;
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { readConfig, registryFor } from '../config.js';
4
4
  import { isItemInstalled } from '../files.js';
5
+ import { installedSources } from './diff.js';
5
6
  import { adaptItem, resolveLayout } from '../layout.js';
6
7
  import { color } from '../output.js';
7
8
  import { declaredDependencies, detectPackageManager, findApp, readPackageJson } from '../project.js';
@@ -26,7 +27,7 @@ export async function collectInfo(cwd, version) {
26
27
  app: location?.app?.name ?? null,
27
28
  apps: location?.ambiguous?.map(a => a.name) ?? null,
28
29
  angular: deps['@angular/core'] ?? null,
29
- analog: Boolean(deps['@analogjs/platform'] || deps['@analogjs/vite-plugin-angular']),
30
+ analog: Boolean(deps['@analogjs/platform']),
30
31
  nx: Boolean(deps['nx'] || (packageRoot && existsSync(path.join(packageRoot, 'nx.json')))),
31
32
  packageManager: packageRoot && pkg ? detectPackageManager(packageRoot, pkg) : null,
32
33
  tailwind: deps['tailwindcss'] ?? null,
@@ -61,6 +62,13 @@ export async function collectInfo(cwd, version) {
61
62
  catch (error) {
62
63
  info.registry.error = error instanceof Error ? error.message : String(error);
63
64
  }
65
+ // Components added from a URL or file, recorded by `add`.
66
+ if (root && config?.sources && layout) {
67
+ const found = new Set((await installedSources(root, registryFor(root, config, cwd), config.sources, layout)).values());
68
+ for (const [name, source] of Object.entries(config.sources))
69
+ if (found.has(source))
70
+ info.installed.push(`${name} (${source})`);
71
+ }
64
72
  // Named registries: best effort, an unreachable one just adds nothing.
65
73
  if (root && config?.registries) {
66
74
  const registry = registryFor(root, config, cwd);
@@ -98,7 +106,7 @@ export async function runInfo(options) {
98
106
  row('package manager', info.project.packageManager ?? '—'),
99
107
  row('tailwind', yes(info.project.tailwind)),
100
108
  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'}`),
109
+ row('analog / nx', `${info.project.analog ? 'analog' : 'no'} / ${info.project.nx ? 'nx' : 'no'}`),
102
110
  '',
103
111
  color.bold('components.json'),
104
112
  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`')),
@@ -7,10 +7,10 @@ import { writeItemFiles } from '../files.js';
7
7
  import { adaptItem, resolveLayout } from '../layout.js';
8
8
  import { color, log } from '../output.js';
9
9
  import { installPackages, missingPackages } from '../packages.js';
10
- import { declaredDependencies, loadProject } from '../project.js';
10
+ import { declaredDependencies, isNxWorkspace, loadProject } from '../project.js';
11
11
  import { confirm, isInteractive } from '../prompts.js';
12
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';
13
+ import { addGlobalStyle, appTsconfigEdit, planTailwind, readPostcssConfig, relativeImport, withTailwindPlugin, tsconfigsToEdit, unlayeredResets, withTokensImport, writeIfChanged, } from '../setup.js';
14
14
  export async function runInit(options) {
15
15
  const project = await loadProject(options.cwd);
16
16
  const { root, packageRoot } = project;
@@ -74,14 +74,17 @@ export async function runInit(options) {
74
74
  done.push('Tailwind CSS v4 installed (add the Vite plugin)');
75
75
  }
76
76
  // 3. tsconfig aliases the component sources import.
77
- const tsconfigs = tsconfigsToEdit(root);
77
+ // Nx keeps every path in the shared tsconfig.base.json: an app tsconfig with its own `paths`
78
+ // would stop seeing libraries added there later.
79
+ const nxBase = path.join(packageRoot, 'tsconfig.base.json');
80
+ const tsconfigs = isNxWorkspace(packageRoot) && existsSync(nxBase) ? [path.relative(root, nxBase)] : tsconfigsToEdit(root);
78
81
  if (!tsconfigs.length)
79
82
  throw new UipkgeError(`No tsconfig.json in ${root}.`);
80
83
  const edited = [];
81
84
  for (const file of tsconfigs) {
82
85
  const abs = path.join(root, file);
83
86
  if (await writeIfChanged(abs, appTsconfigEdit(root, file, await readFile(abs, 'utf8'))))
84
- edited.push(file);
87
+ edited.push(path.relative(packageRoot, abs).replace(/\\/g, '/'));
85
88
  }
86
89
  if (edited.length)
87
90
  done.push(`tsconfig paths \`@/*\` and \`@/ui/*\` added (${edited.join(', ')})`);
@@ -106,6 +109,12 @@ export async function runInit(options) {
106
109
  await linkTokens(project, config.styles, config.tokens, done);
107
110
  await writeConfig(root, config);
108
111
  done.push('components.json written');
112
+ // An unlayered `* { padding: 0 }` beats Tailwind v4's layered utilities and strips every component's spacing.
113
+ const stylesPath = path.join(root, config.styles);
114
+ const resets = existsSync(stylesPath) ? unlayeredResets(await readFile(stylesPath, 'utf8')) : [];
115
+ if (resets.length) {
116
+ log.warn(`${config.styles} resets margin/padding on \`${resets[0]}\` outside a CSS layer; it will override Tailwind's utilities. Move it into \`@layer base { … }\`.`);
117
+ }
109
118
  log.blank();
110
119
  log.success(color.bold('uipkge is ready.'));
111
120
  for (const line of done)
@@ -134,8 +143,9 @@ async function linkTokens(project, styles, tokens, done) {
134
143
  // Sass/Less: a CSS import there doesn't carry Tailwind v4's syntax reliably, so the tokens become their own global stylesheet.
135
144
  if (!styles.endsWith('.css') && project.app) {
136
145
  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}`);
146
+ const where = path.relative(project.packageRoot, project.app.config.file).replace(/\\/g, '/');
147
+ if (await addGlobalStyle(project.app.config, entry))
148
+ done.push(`${where} styles include ${entry}`);
139
149
  return;
140
150
  }
141
151
  if (!existsSync(stylesFile)) {
@@ -1,5 +1,5 @@
1
1
  import * as path from 'node:path';
2
- import { readConfig, registryFor } from '../config.js';
2
+ import { fromSource, readConfig, registryFor } from '../config.js';
3
3
  import { UipkgeError } from '../errors.js';
4
4
  import { isItemInstalled } from '../files.js';
5
5
  import { adaptItem, resolveLayout } from '../layout.js';
@@ -17,6 +17,8 @@ export function firstSentence(text) {
17
17
  return end === -1 ? flat : flat.slice(0, end);
18
18
  }
19
19
  const OTHER = 'other';
20
+ /** Group for components added from a URL or local file (components.json `sources`). */
21
+ export const SOURCES_GROUP = 'added from a url or file';
20
22
  /** Filters, then groups by first category (alphabetical, `other` last), rows sorted by name. */
21
23
  export function groupRows(items, filter) {
22
24
  const query = filter.query?.trim().toLowerCase();
@@ -43,9 +45,11 @@ export function groupRows(items, filter) {
43
45
  for (const row of rows)
44
46
  byCategory.set(row.category, [...(byCategory.get(row.category) ?? []), row]);
45
47
  return [...byCategory]
46
- .sort(([a], [b]) => (a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b)))
48
+ // Registry categories alphabetically, then `other`, then components from URLs/files.
49
+ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
47
50
  .map(([cat, list]) => ({ category: cat, rows: list.sort((x, y) => x.name.localeCompare(y.name)) }));
48
51
  }
52
+ const rank = (category) => (category === SOURCES_GROUP ? 2 : category === OTHER ? 1 : 0);
49
53
  export const ellipsize = (text, width) => width < 2 ? '' : text.length <= width ? text : `${text.slice(0, width - 1).trimEnd()}…`;
50
54
  export async function runList(options) {
51
55
  const cwd = path.resolve(options.cwd);
@@ -66,7 +70,21 @@ export async function runList(options) {
66
70
  layout = undefined;
67
71
  }
68
72
  }
69
- const groups = groupRows(options.namespace ? index.items.map(i => ({ ...i, name: `${options.namespace}/${i.name}` })) : index.items, {
73
+ // Components added from a URL or file (recorded by `add`) aren't in any index; list them in their own group.
74
+ const fromSources = [];
75
+ if (!options.namespace && root && config?.sources) {
76
+ for (const source of Object.values(config.sources)) {
77
+ try {
78
+ const item = await registry.item(fromSource(root, source));
79
+ fromSources.push({ ...item, categories: [SOURCES_GROUP], description: `${source}${item.description ? ` — ${item.description}` : ''}` });
80
+ }
81
+ catch {
82
+ // An unreachable source is reported by `doctor`; browsing shouldn't fail on it.
83
+ }
84
+ }
85
+ }
86
+ const items = options.namespace ? index.items.map(i => ({ ...i, name: `${options.namespace}/${i.name}` })) : [...index.items, ...fromSources];
87
+ const groups = groupRows(items, {
70
88
  query: options.query,
71
89
  category: options.category,
72
90
  installedOnly: options.installedOnly,
package/dist/config.js CHANGED
@@ -3,9 +3,17 @@ import { readFile, writeFile } from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { projectEnv } from './env.js';
5
5
  import { UipkgeError } from './errors.js';
6
- import { createRegistry, resolveRegistryUrl } from './registry.js';
6
+ import { createRegistry, refKind, resolveRegistryUrl } from './registry.js';
7
7
  export const CONFIG_FILE = 'components.json';
8
8
  export const DEFAULT_ALIASES = { ui: '@/ui', lib: '@/lib', blocks: '@/app/components/blocks' };
9
+ /** How `sources` stores a ref: URLs as they are, local files relative to the app so the entry stays portable. */
10
+ export function toSource(root, cwd, ref) {
11
+ return refKind(ref) === 'file' ? path.relative(root, path.resolve(cwd, ref)).replace(/\\/g, '/') : ref;
12
+ }
13
+ /** A `sources` entry as a ref the registry client can load from anywhere. */
14
+ export function fromSource(root, source) {
15
+ return refKind(source) === 'url' ? source : path.resolve(root, source);
16
+ }
9
17
  export function defaultConfig(registryUrl) {
10
18
  return {
11
19
  framework: 'angular',
package/dist/project.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { parse } from 'jsonc-parser';
@@ -52,23 +52,53 @@ const isInside = (child, parent) => {
52
52
  const rel = path.relative(parent, child);
53
53
  return !rel.startsWith('..') && !path.isAbsolute(rel);
54
54
  };
55
- /** Application projects in angular.json (libraries have no global styles to theme). */
55
+ function toApp(packageRoot, name, p, config, rootDir) {
56
+ const root = rootDir ?? path.resolve(packageRoot, p.root ?? '');
57
+ const styles = ((p.architect ?? p.targets)?.build?.options?.styles ?? [])
58
+ .map(s => (typeof s === 'string' ? s : s.input))
59
+ .filter((s) => Boolean(s))
60
+ .map(s => path.relative(root, path.resolve(packageRoot, s)).replace(/\\/g, '/'));
61
+ return { name, root, styles, config };
62
+ }
63
+ /** project.json files of an Nx workspace (a few levels deep, skipping build output and dependencies). */
64
+ function nxProjectFiles(dir, depth = 0, out = []) {
65
+ if (depth > 4)
66
+ return out;
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ if (entry.isFile() && entry.name === 'project.json')
69
+ out.push(path.join(dir, entry.name));
70
+ else if (entry.isDirectory() && !/^(node_modules|dist|tmp|coverage|\..*)$/.test(entry.name))
71
+ nxProjectFiles(path.join(dir, entry.name), depth + 1, out);
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * Angular application projects (libraries have no global styles to theme): from
77
+ * angular.json, or in an Nx workspace from each app's project.json, keeping only
78
+ * apps whose build target is Angular's.
79
+ */
56
80
  export function angularApps(packageRoot) {
57
81
  const file = path.join(packageRoot, 'angular.json');
58
- if (!existsSync(file))
82
+ if (existsSync(file)) {
83
+ const doc = (parse(readFileSync(file, 'utf8')) ?? {});
84
+ return Object.entries(doc.projects ?? {})
85
+ .filter(([, p]) => (p.projectType ?? 'application') === 'application')
86
+ .map(([name, p]) => toApp(packageRoot, name, p, { file, path: ['projects', name] }));
87
+ }
88
+ if (!existsSync(path.join(packageRoot, 'nx.json')))
59
89
  return [];
60
- const doc = (parse(readFileSync(file, 'utf8')) ?? {});
61
- return Object.entries(doc.projects ?? {})
62
- .filter(([, p]) => (p.projectType ?? 'application') === 'application')
63
- .map(([name, p]) => {
64
- const root = path.resolve(packageRoot, p.root ?? '');
65
- const styles = ((p.architect ?? p.targets)?.build?.options?.styles ?? [])
66
- .map(s => (typeof s === 'string' ? s : s.input))
67
- .filter((s) => Boolean(s))
68
- .map(s => path.relative(root, path.resolve(packageRoot, s)).replace(/\\/g, '/'));
69
- return { name, root, styles };
90
+ return nxProjectFiles(packageRoot).flatMap(projectFile => {
91
+ const p = (parse(readFileSync(projectFile, 'utf8')) ?? {});
92
+ const build = p.targets?.build;
93
+ const angularBuild = /angular/.test(build?.executor ?? build?.builder ?? '');
94
+ if (p.projectType !== 'application' || !angularBuild)
95
+ return [];
96
+ const root = path.dirname(projectFile);
97
+ return [toApp(packageRoot, p.name ?? path.basename(root), p, { file: projectFile, path: [] }, root)];
70
98
  });
71
99
  }
100
+ /** An Nx workspace: its tsconfig paths live in the shared tsconfig.base.json. */
101
+ export const isNxWorkspace = (packageRoot) => existsSync(path.join(packageRoot, 'nx.json'));
72
102
  /**
73
103
  * Which Angular app `cwd` belongs to. An existing components.json between cwd
74
104
  * and the package root wins; otherwise the angular.json project containing
@@ -112,16 +142,15 @@ export async function loadProject(cwd) {
112
142
  if (!angularCore) {
113
143
  throw new UipkgeError(`${root} is not an Angular project (no @angular/core in package.json).`);
114
144
  }
115
- if (deps['nx'] || existsSync(path.join(packageRoot, 'nx.json'))) {
116
- throw new UipkgeError('Nx workspaces are not supported yet.', 'Run uipkge-ng inside a standalone Angular CLI app.');
117
- }
118
145
  return {
119
146
  root,
120
147
  packageRoot,
121
148
  app: location.app,
122
149
  packageJson,
123
150
  angularCore,
124
- isAnalog: Boolean(deps['@analogjs/platform'] || deps['@analogjs/vite-plugin-angular']),
151
+ // The Analog framework itself. @analogjs/vite-plugin-angular alone is common in Angular CLI and
152
+ // Nx workspaces (Vitest), and those still build with the Angular CLI, not Vite.
153
+ isAnalog: Boolean(deps['@analogjs/platform']),
125
154
  hasTailwind: Boolean(deps['tailwindcss']),
126
155
  packageManager: detectPackageManager(packageRoot, packageJson),
127
156
  };
package/dist/setup.js CHANGED
@@ -142,21 +142,23 @@ export async function writeIfChanged(file, next) {
142
142
  }
143
143
  /**
144
144
  * Puts `entry` (relative to the workspace root) first in the app's global
145
- * `styles` for every target that has them (build, test), keeping angular.json's
145
+ * `styles` for every target that has them (build, test), in whichever file
146
+ * configures the app — angular.json or an Nx project.json — keeping its
146
147
  * formatting. Returns whether anything changed.
147
148
  */
148
- export async function addGlobalStyle(packageRoot, project, entry) {
149
- const file = path.join(packageRoot, 'angular.json');
150
- let text = await readFile(file, 'utf8');
151
- const doc = parse(text);
152
- const entryOf = doc.projects?.[project];
153
- const key = entryOf?.architect ? 'architect' : 'targets';
149
+ export async function addGlobalStyle(config, entry) {
150
+ let text = await readFile(config.file, 'utf8');
151
+ let node = parse(text);
152
+ for (const key of config.path)
153
+ node = node?.[key];
154
+ const project = node;
155
+ const key = project?.architect ? 'architect' : 'targets';
154
156
  let changed = false;
155
- for (const [name, target] of Object.entries(entryOf?.[key] ?? {})) {
157
+ for (const [name, target] of Object.entries(project?.[key] ?? {})) {
156
158
  const styles = target.options?.styles;
157
159
  if (!Array.isArray(styles) || styles.some(s => (typeof s === 'string' ? s : s.input) === entry))
158
160
  continue;
159
- const edits = modify(text, ['projects', project, key, name, 'options', 'styles', 0], entry, {
161
+ const edits = modify(text, [...config.path, key, name, 'options', 'styles', 0], entry, {
160
162
  isArrayInsertion: true,
161
163
  formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' },
162
164
  });
@@ -164,6 +166,35 @@ export async function addGlobalStyle(packageRoot, project, entry) {
164
166
  changed = true;
165
167
  }
166
168
  if (changed)
167
- await writeFile(file, text, 'utf8');
169
+ await writeFile(config.file, text, 'utf8');
168
170
  return changed;
169
171
  }
172
+ /**
173
+ * Universal rules outside any `@layer` that zero margin or padding (`* { padding: 0 }`). Tailwind v4 puts utilities in a cascade layer, and unlayered CSS
174
+ * always wins over layered CSS, so a reset like this strips the spacing from every component.
175
+ * Returns the offending selectors.
176
+ */
177
+ export function unlayeredResets(css) {
178
+ let text = css.replace(/\/\*[\s\S]*?\*\//g, '');
179
+ // Drop @layer blocks (brace-matched) — rules inside a layer are fine.
180
+ for (let i = text.search(/@layer\b[^;{]*\{/); i !== -1; i = text.search(/@layer\b[^;{]*\{/)) {
181
+ let depth = 0;
182
+ let j = text.indexOf('{', i);
183
+ for (; j < text.length; j++) {
184
+ if (text[j] === '{')
185
+ depth++;
186
+ else if (text[j] === '}' && --depth === 0)
187
+ break;
188
+ }
189
+ text = text.slice(0, i) + text.slice(j + 1);
190
+ }
191
+ const found = [];
192
+ for (const m of text.matchAll(/(^|[};])\s*([^{};@]+?)\s*\{([^{}]*)\}/g)) {
193
+ const selector = m[2].trim().replace(/\s+/g, ' ');
194
+ // Only the universal selector reaches component elements; `html, body { margin: 0 }` is harmless.
195
+ const universal = selector.split(',').some(part => /^\*(::?[a-z-]+)?$/.test(part.trim()));
196
+ if (universal && /(^|[;\s])(margin|padding)\s*:\s*0(px)?\s*(;|$)/.test(m[3]))
197
+ found.push(selector);
198
+ }
199
+ return found;
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uipkge-ng",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Add uipkge Angular components to your project: copy the source, own the code.",
5
5
  "license": "MIT",
6
6
  "author": "Uday Adaka (https://uipkge.dev)",