@hkdigital/lib-core 0.5.68 → 0.5.70

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/README.md CHANGED
@@ -280,9 +280,26 @@ pnpm run publish:npm # Version bump and publish to npm
280
280
  ### Import Validation
281
281
 
282
282
  The library includes a validation script to enforce consistent import
283
- patterns. Run it in your project:
283
+ patterns across both your project files and external `@hkdigital/*`
284
+ package imports.
285
+
286
+ **Add to your project's package.json:**
287
+
288
+ ```json
289
+ {
290
+ "scripts": {
291
+ "lint:imports": "node node_modules/@hkdigital/lib-core/scripts/validate-imports.mjs"
292
+ }
293
+ }
294
+ ```
295
+
296
+ **Run validation:**
284
297
 
285
298
  ```bash
299
+ # Using npm script (recommended)
300
+ pnpm run lint:imports
301
+
302
+ # Or directly
286
303
  node node_modules/@hkdigital/lib-core/scripts/validate-imports.mjs
287
304
  ```
288
305
 
@@ -295,6 +312,8 @@ node node_modules/@hkdigital/lib-core/scripts/validate-imports.mjs
295
312
  `.svelte.js`)
296
313
  5. **Directory imports** - Write explicitly or create barrel export file
297
314
  6. **File existence** - All import paths must resolve to existing files
315
+ 7. **External package optimization** - Suggests barrel exports for
316
+ `@hkdigital/*` packages
298
317
 
299
318
  **Barrel export preference:**
300
319
 
@@ -303,18 +322,26 @@ exports your target. This encourages shorter imports that can be
303
322
  combined:
304
323
 
305
324
  ```js
306
- // Instead of deep imports:
325
+ // Internal imports - instead of deep imports:
307
326
  import ProfileBlocks from '$lib/ui/components/profile-blocks/ProfileBlocks.svelte';
308
327
  import Button from '$lib/ui/primitives/buttons/Button.svelte';
309
328
 
310
329
  // Use barrel exports:
311
330
  import { ProfileBlocks } from '$lib/ui/components.js';
312
331
  import { Button } from '$lib/ui/primitives.js';
332
+
333
+ // External imports - instead of deep imports:
334
+ import { TextButton } from '@hkdigital/lib-core/ui/primitives/buttons/index.js';
335
+ import { TextInput } from '@hkdigital/lib-core/ui/primitives/inputs/index.js';
336
+
337
+ // Use barrel exports:
338
+ import { TextButton, TextInput } from '@hkdigital/lib-core/ui/primitives.js';
313
339
  ```
314
340
 
315
341
  The validator checks from highest to lowest level (`$lib/ui.js` →
316
342
  `$lib/ui/components.js` → `$lib/ui/components/profile-blocks.js`) and
317
- suggests the highest-level file that exports your target.
343
+ suggests the highest-level file that exports your target. The same
344
+ logic applies to external `@hkdigital/*` packages.
318
345
 
319
346
  **Routes are exempt from strict rules:**
320
347
 
@@ -334,11 +361,25 @@ src/lib/ui/pages/Profile.svelte:8
334
361
  from '$lib/ui/components/profile-blocks/ProfileBlocks.svelte'
335
362
  => from '$lib/ui/components.js' (use barrel export for shorter imports)
336
363
 
364
+ src/lib/forms/LoginForm.svelte:4
365
+ from '@hkdigital/lib-core/ui/primitives/buttons/index.js'
366
+ => from '@hkdigital/lib-core/ui/primitives.js' (use barrel export)
367
+
337
368
  src/routes/explorer/[...path]/+page.svelte:4
338
369
  from '../components/index.js'
339
370
  ✅ Allowed in routes
340
371
  ```
341
372
 
373
+ **What gets checked for external packages:**
374
+
375
+ The validator only suggests barrel exports for:
376
+ - Explicit `index.js` imports
377
+ - Component files (`.svelte`)
378
+ - Class files (capitalized `.js` files)
379
+
380
+ Intentional imports like `helpers.js`, `config.js`, or other lowercase
381
+ utility files are assumed to be the public API and won't be flagged.
382
+
342
383
  ### Import Patterns and Export Structure
343
384
 
344
385
  **Public exports use domain-specific files matching folder names:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hkdigital/lib-core",
3
- "version": "0.5.68",
3
+ "version": "0.5.70",
4
4
  "author": {
5
5
  "name": "HKdigital",
6
6
  "url": "https://hkdigital.nl"
@@ -6,6 +6,12 @@ import { join, relative, resolve, dirname } from 'node:path';
6
6
  const PROJECT_ROOT = process.cwd();
7
7
  const SRC_DIR = join(PROJECT_ROOT, 'src');
8
8
 
9
+ /**
10
+ * Scopes to validate for barrel exports
11
+ * Any package under these scopes will be checked
12
+ */
13
+ const EXTERNAL_SCOPES_TO_VALIDATE = ['@hkdigital'];
14
+
9
15
  /**
10
16
  * Find all JS and Svelte files recursively
11
17
  *
@@ -141,6 +147,173 @@ async function isDirectoryWithIndex(fsPath) {
141
147
  return false;
142
148
  }
143
149
 
150
+ /**
151
+ * Extract imported names from import statement
152
+ *
153
+ * @param {string} line - Import statement line
154
+ *
155
+ * @returns {string[]} Array of imported names
156
+ */
157
+ function extractImportNames(line) {
158
+ const names = [];
159
+
160
+ // Default import: import Foo from '...'
161
+ const defaultMatch = line.match(/import\s+(\w+)\s+from/);
162
+ if (defaultMatch) {
163
+ names.push(defaultMatch[1]);
164
+ }
165
+
166
+ // Named imports: import { Foo, Bar } from '...'
167
+ const namedMatch = line.match(/import\s+\{([^}]+)\}\s+from/);
168
+ if (namedMatch) {
169
+ const namedImports = namedMatch[1]
170
+ .split(',')
171
+ .map(name => {
172
+ // Handle 'as' aliases: { Foo as Bar } → extract 'Foo'
173
+ const parts = name.trim().split(/\s+as\s+/);
174
+ return parts[0].trim();
175
+ })
176
+ .filter(name => name && name !== '*');
177
+ names.push(...namedImports);
178
+ }
179
+
180
+ return names;
181
+ }
182
+
183
+ /**
184
+ * Find highest-level barrel export in external package
185
+ *
186
+ * For @hkdigital/lib-core/ui/primitives/buttons/index.js:
187
+ * - Check @hkdigital/lib-core/ui/primitives.js
188
+ * - Check @hkdigital/lib-core/ui.js
189
+ *
190
+ * @param {string} importPath - External import path
191
+ * @param {string} targetName - Name of export to find
192
+ *
193
+ * @returns {Promise<string|null>} Suggested barrel path or null
194
+ */
195
+ async function findExternalBarrelExport(importPath, targetName) {
196
+ // Extract package name (handle scoped packages)
197
+ const parts = importPath.split('/');
198
+ let pkgName;
199
+ let pathInPackage;
200
+
201
+ if (importPath.startsWith('@')) {
202
+ // Scoped package: @scope/package/path/to/file
203
+ pkgName = `${parts[0]}/${parts[1]}`;
204
+ pathInPackage = parts.slice(2);
205
+ } else {
206
+ // Regular package: package/path/to/file
207
+ pkgName = parts[0];
208
+ pathInPackage = parts.slice(1);
209
+ }
210
+
211
+ // Check if this scope should be validated
212
+ const scope = pkgName.startsWith('@') ?
213
+ pkgName.split('/')[0] : null;
214
+ if (scope && !EXTERNAL_SCOPES_TO_VALIDATE.includes(scope)) {
215
+ return null;
216
+ }
217
+
218
+ // If no path in package, nothing to suggest
219
+ if (pathInPackage.length === 0) return null;
220
+
221
+ const nodeModulesPath = join(PROJECT_ROOT, 'node_modules', pkgName);
222
+
223
+ // Extract target to find (last part without extension)
224
+ const lastPart = pathInPackage[pathInPackage.length - 1];
225
+ const targetBase = lastPart.replace(/\.(js|svelte)$/, '');
226
+
227
+ // Only check for specific import types (matches internal logic)
228
+ // 1. Explicit index.js imports
229
+ // 2. Component files (.svelte)
230
+ // 3. Class files (capitalized .js)
231
+ let shouldCheck = false;
232
+
233
+ if (lastPart === 'index.js') {
234
+ shouldCheck = true;
235
+ } else if (lastPart.endsWith('.svelte')) {
236
+ shouldCheck = true;
237
+ } else if (lastPart.match(/^[A-Z][^/]*\.js$/)) {
238
+ shouldCheck = true;
239
+ }
240
+
241
+ if (!shouldCheck) return null;
242
+
243
+ // Read package.json to check for exports mapping
244
+ let exportsMapping = null;
245
+ try {
246
+ const pkgJsonPath = join(nodeModulesPath, 'package.json');
247
+ const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8');
248
+ const pkgJson = JSON.parse(pkgJsonContent);
249
+
250
+ // Check if there's a "./*" export mapping
251
+ if (pkgJson.exports && pkgJson.exports['./*']) {
252
+ const mapping = pkgJson.exports['./*'];
253
+ const mappingStr = typeof mapping === 'string' ?
254
+ mapping : mapping.default;
255
+
256
+ // Extract prefix from mapping like "./dist/*" -> "dist/"
257
+ if (mappingStr && mappingStr.includes('*')) {
258
+ exportsMapping = mappingStr.replace(/\/?\*$/, '');
259
+ if (exportsMapping.startsWith('./')) {
260
+ exportsMapping = exportsMapping.slice(2);
261
+ }
262
+ if (exportsMapping && !exportsMapping.endsWith('/')) {
263
+ exportsMapping += '/';
264
+ }
265
+ }
266
+ }
267
+ } catch {
268
+ // Could not read package.json, continue without mapping
269
+ }
270
+
271
+ // Try progressively higher-level barrel files
272
+ for (let i = 1; i < pathInPackage.length; i++) {
273
+ const barrelPath = pathInPackage.slice(0, i).join('/') + '.js';
274
+
275
+ // Try both with and without exports mapping
276
+ const pathsToTry = [
277
+ join(nodeModulesPath, barrelPath),
278
+ exportsMapping ?
279
+ join(nodeModulesPath, exportsMapping + barrelPath) : null
280
+ ].filter(Boolean);
281
+
282
+ for (const fsBarrelPath of pathsToTry) {
283
+ try {
284
+ const stats = await stat(fsBarrelPath);
285
+ if (stats.isFile()) {
286
+ const content = await readFile(fsBarrelPath, 'utf-8');
287
+
288
+ // Check if this barrel exports our target
289
+ // Patterns to match:
290
+ // export { TextButton } from './path';
291
+ // export * from './path';
292
+ const exportPatterns = [
293
+ // Named export with exact name
294
+ new RegExp(
295
+ `export\\s+\\{[^}]*\\b${targetName}\\b[^}]*\\}`,
296
+ 'm'
297
+ ),
298
+ // Re-export all
299
+ /export\s+\*\s+from/,
300
+ // Default export
301
+ new RegExp(`export\\s+default\\s+${targetName}\\b`, 'm')
302
+ ];
303
+
304
+ if (exportPatterns.some(pattern => pattern.test(content))) {
305
+ return `${pkgName}/${barrelPath}`;
306
+ }
307
+ }
308
+ } catch {
309
+ // File doesn't exist, continue
310
+ }
311
+ }
312
+ }
313
+
314
+ return null;
315
+ }
316
+
144
317
  /**
145
318
  * Validate import paths in a file
146
319
  *
@@ -179,10 +352,40 @@ async function validateFile(filePath) {
179
352
  // Strip query parameters (Vite asset imports like ?preset=render)
180
353
  const importPath = importPathRaw.split('?')[0];
181
354
 
182
- // Skip external packages (no ./ or $lib prefix)
183
- if (!importPath.startsWith('./') &&
184
- !importPath.startsWith('../') &&
185
- !importPath.startsWith('$lib/')) {
355
+ // Check external packages from configured scopes
356
+ const isExternalPackage = !importPath.startsWith('./') &&
357
+ !importPath.startsWith('../') &&
358
+ !importPath.startsWith('$lib/');
359
+
360
+ if (isExternalPackage) {
361
+ // Extract package name/scope
362
+ const parts = importPath.split('/');
363
+ const scope = importPath.startsWith('@') ? parts[0] : null;
364
+
365
+ // Check if this scope should be validated
366
+ if (scope && EXTERNAL_SCOPES_TO_VALIDATE.includes(scope)) {
367
+ // Extract imported names from the import statement
368
+ const importedNames = extractImportNames(line);
369
+
370
+ // Check each imported name for barrel exports
371
+ for (const importedName of importedNames) {
372
+ const barrelPath = await findExternalBarrelExport(
373
+ importPath,
374
+ importedName
375
+ );
376
+
377
+ if (barrelPath) {
378
+ errors.push(
379
+ `${relativePath}:${lineNum}\n` +
380
+ ` from '${importPath}'\n` +
381
+ ` => from '${barrelPath}' (use barrel export)`
382
+ );
383
+ break; // Only report once per line
384
+ }
385
+ }
386
+ }
387
+
388
+ // Skip further validation for external packages
186
389
  continue;
187
390
  }
188
391