@likec4/config 1.47.0 → 1.49.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.
@@ -1,53 +1,128 @@
1
1
  import { invariant } from '@likec4/core'
2
+ import { logger, wrapError } from '@likec4/log'
2
3
  import { bundleRequire } from 'bundle-require'
4
+ import { defu } from 'defu'
5
+ import { formatMessagesSync } from 'esbuild'
6
+ import JSON5 from 'json5'
3
7
  import * as fs from 'node:fs/promises'
4
- import { dirname } from 'node:path'
5
- import type { URI } from 'vscode-uri'
6
- import { defineConfig } from '../define-config'
8
+ import { basename, dirname, resolve } from 'node:path'
9
+ import { hasAtLeast, isNonNullish, last, omit } from 'remeda'
10
+ import z from 'zod/v4'
7
11
  import { isLikeC4JsonConfig, isLikeC4NonJsonConfig } from '../filenames'
8
- import { logger } from '../logger'
9
- import { type LikeC4ProjectConfig, validateProjectConfig } from '../schema'
12
+ import type { LikeC4ProjectConfig, VscodeURI } from '../schema'
13
+ import { LikeC4ProjectJsonConfigSchema, validateProjectConfig } from '../schema'
14
+
15
+ const JsonConfigInputSchema = LikeC4ProjectJsonConfigSchema.pick({
16
+ extends: true,
17
+ styles: true,
18
+ }).loose()
19
+
20
+ type JsonConfigInput = z.infer<typeof JsonConfigInputSchema>
21
+ type JsonConfigStyles = NonNullable<JsonConfigInput['styles']>
22
+
23
+ const normalizeExtends = (value: JsonConfigInput['extends']): string[] => {
24
+ if (!value) {
25
+ return []
26
+ }
27
+ return Array.isArray(value) ? value : [value]
28
+ }
29
+
30
+ const parseJsonConfig = async (filepath: string): Promise<JsonConfigInput> => {
31
+ const content = await fs.readFile(filepath, 'utf-8')
32
+ let parsed: unknown
33
+ try {
34
+ parsed = JSON5.parse(content.trim() || '{}')
35
+ } catch (e) {
36
+ throw wrapError(e, `${filepath}:`)
37
+ }
38
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
39
+ throw new Error(`${filepath}: Config must be a JSON object`)
40
+ }
41
+ const result = JsonConfigInputSchema.safeParse(parsed)
42
+ if (!result.success) {
43
+ throw new Error(`${filepath}: Invalid config\n` + z.prettifyError(result.error))
44
+ }
45
+ return result.data
46
+ }
47
+
48
+ const loadJsonConfigs = async (filepath: string, stack: string[]): Promise<[...JsonConfigInput[], JsonConfigInput]> => {
49
+ if (stack.includes(filepath)) {
50
+ const cycleStart = stack.indexOf(filepath)
51
+ const cycle = [...stack.slice(cycleStart), filepath].join(' -> ')
52
+ throw new Error(`Config extends cycle detected: ${cycle}`)
53
+ }
54
+
55
+ const parsed = await parseJsonConfig(filepath)
56
+ const extendsPaths = normalizeExtends(parsed.extends)
57
+ const nextStack = [...stack, filepath]
58
+
59
+ const configs: JsonConfigInput[] = []
60
+ for (const extendPath of extendsPaths) {
61
+ const resolvedPath = resolve(dirname(filepath), extendPath)
62
+ configs.push(...await loadJsonConfigs(resolvedPath, nextStack))
63
+ }
64
+ return [...configs, parsed]
65
+ }
10
66
 
11
67
  /**
12
68
  * Load LikeC4 Project config file.
13
69
  * If filepath is a non-JSON file, it will be bundled and required
14
70
  */
15
- export async function loadConfig(filepath: URI): Promise<LikeC4ProjectConfig> {
16
- logger.debug`Loading config file: ${filepath.fsPath}`
17
- if (isLikeC4JsonConfig(filepath.fsPath)) {
18
- try {
19
- const content = await fs.readFile(filepath.fsPath, 'utf-8')
20
- return validateProjectConfig(content)
21
- } catch (err) {
22
- logger.error(`Failed to load json config file: ${filepath.fsPath}`, { err })
23
- throw err
24
- }
71
+ export async function loadConfig(filepath: VscodeURI | string): Promise<LikeC4ProjectConfig> {
72
+ filepath = typeof filepath === 'string' ? filepath : filepath.fsPath
73
+ logger.getChild('config').debug`Loading config: ${filepath}`
74
+
75
+ const folder = dirname(filepath)
76
+ const filename = basename(filepath)
77
+ const implicitcfg = { name: basename(folder) }
78
+
79
+ if (isLikeC4JsonConfig(filename)) {
80
+ const configs = await loadJsonConfigs(resolve(filepath), [])
81
+ invariant(hasAtLeast(configs, 1), 'Expect at least one config')
82
+ const rootConfig = omit(last(configs), ['extends', 'styles'])
83
+ const stylesChain = configs
84
+ .map(config => config.styles)
85
+ .filter(isNonNullish)
86
+
87
+ const mergedStyles = stylesChain.length > 0
88
+ ? defu({}, ...stylesChain.reverse())
89
+ : undefined
90
+
91
+ return validateProjectConfig({
92
+ ...implicitcfg,
93
+ ...rootConfig,
94
+ ...(mergedStyles ? { styles: mergedStyles } : {}),
95
+ })
25
96
  }
26
97
 
27
- invariant(isLikeC4NonJsonConfig(filepath.fsPath), `Invalid config file: ${filepath.fsPath}`)
28
- try {
29
- const cwd = dirname(filepath.fsPath)
30
- const { mod } = await bundleRequire({
31
- filepath: filepath.fsPath,
32
- cwd,
33
- esbuildOptions: {
34
- resolveExtensions: ['.mjs', '.js', '.ts', '.mts'],
35
- plugins: [{
36
- name: 'likec4-config',
37
- setup(build) {
38
- /**
39
- * Intercept @likec4/config imports
40
- */
41
- build.onResolve({ filter: /^@?likec4\/config$/ }, (args) => ({
42
- path: args.path,
43
- namespace: 'likec4-config',
44
- }))
45
- /**
46
- * Mock implementation, this allows to skip redundant bundling @likec4/config
47
- */
48
- build.onLoad({ filter: /.*/, namespace: 'likec4-config' }, (_args) => {
49
- return {
50
- contents: `
98
+ invariant(isLikeC4NonJsonConfig(filename), `Invalid name for config file: ${filepath}`)
99
+ const { mod } = await bundleRequire({
100
+ filepath,
101
+ cwd: folder,
102
+ esbuildOptions: {
103
+ resolveExtensions: ['.ts', '.mts', '.cts', '.mjs', '.js', '.cjs'],
104
+ plugins: [{
105
+ name: 'likec4-config',
106
+ setup(build) {
107
+ /**
108
+ * Intercept @likec4/config and likec4/config imports
109
+ */
110
+ build.onResolve({ filter: /^@?likec4\/config$/ }, (args) => ({
111
+ path: args.path,
112
+ namespace: 'likec4-config',
113
+ }))
114
+ build.onEnd((result) => {
115
+ const messages = formatMessagesSync(result.errors, { kind: 'error' })
116
+ for (const message of messages) {
117
+ logger.error(message)
118
+ }
119
+ })
120
+ /**
121
+ * Mock implementation, this allows to skip redundant bundling @likec4/config
122
+ */
123
+ build.onLoad({ filter: /.*/, namespace: 'likec4-config' }, (_args) => {
124
+ return {
125
+ contents: `
51
126
  // Mock implementation to allow loading config files without bundling @likec4/config
52
127
  function mock(x) { return x }
53
128
  export {
@@ -57,16 +132,12 @@ export {
57
132
  mock as defineTheme,
58
133
  mock as defineThemeColor,
59
134
  }`,
60
- loader: 'js',
61
- }
62
- })
63
- },
64
- }],
65
- },
66
- })
67
- return defineConfig(mod?.default ?? mod)
68
- } catch (err) {
69
- logger.error(`Failed to load config file: ${filepath.fsPath}`, { err })
70
- throw err
71
- }
135
+ loader: 'js',
136
+ }
137
+ })
138
+ },
139
+ }],
140
+ },
141
+ })
142
+ return validateProjectConfig(Object.assign(implicitcfg, mod?.default ?? mod))
72
143
  }
@@ -1,10 +1,18 @@
1
- import * as z from 'zod'
1
+ import z from 'zod/v4'
2
2
 
3
3
  // Key must be prefixed with "@" and contain only allowed characters
4
4
  const IMAGE_ALIAS_KEY_REGEX = /^@[A-Za-z0-9_-]*$/
5
5
  // Relative path (no leading slash, drive letter, or protocol)
6
6
  const IMAGE_ALIAS_VALUE_REGEX = /^(?!\/|[A-Za-z]:[\\\/])(?!.*:\/\/).*$/
7
7
 
8
+ const ImageAliasKey = z
9
+ .string()
10
+ .min(1, 'Image alias key cannot be empty')
11
+ .regex(
12
+ IMAGE_ALIAS_KEY_REGEX,
13
+ 'Image alias key must match /^@\\w+$/',
14
+ )
15
+
8
16
  // Schema for an image alias value: must be a non-empty string representing a relative path (no leading slash, drive letter, or protocol).
9
17
  const ImageAliasValue = z
10
18
  .string()
@@ -15,54 +23,10 @@ const ImageAliasValue = z
15
23
  )
16
24
 
17
25
  export const ImageAliasesSchema = z.record(
18
- z.string(), // PLAIN key schema - zod JSON schema export-safe.
26
+ ImageAliasKey, // PLAIN key schema - zod JSON schema export-safe.
19
27
  ImageAliasValue,
20
28
  ).meta({
29
+ id: 'ImageAliases',
21
30
  description:
22
31
  'Map of image alias prefixes to relative paths (keys must match /^@\\w+$/; values must be relative paths without protocol or leading slash).',
23
32
  })
24
-
25
- // This just allows us to have a typed validate function.
26
- type LikeC4ImageAliasConfig = z.infer<typeof ImageAliasesSchema>
27
-
28
- export function validateImageAliases(imageAliases?: LikeC4ImageAliasConfig) {
29
- const invalidKeys: string[] = []
30
- const invalidValues: string[] = []
31
-
32
- if (imageAliases) {
33
- for (const [key, value] of Object.entries(imageAliases)) {
34
- if (!IMAGE_ALIAS_KEY_REGEX.test(key)) {
35
- invalidKeys.push(key)
36
- }
37
- // Value regex is technically already enforced by zod,
38
- // so this check is purely defensive.
39
- if (!IMAGE_ALIAS_VALUE_REGEX.test(value)) {
40
- invalidValues.push(`${key} -> ${value}`)
41
- }
42
- }
43
- }
44
-
45
- if (invalidKeys.length || invalidValues.length) {
46
- const parts: string[] = []
47
- if (invalidKeys.length) {
48
- parts.push(
49
- `Invalid image alias key(s): ${
50
- invalidKeys
51
- .map((k) => JSON.stringify(k))
52
- .join(', ')
53
- } (must match ${IMAGE_ALIAS_KEY_REGEX})`,
54
- )
55
- }
56
- if (invalidValues.length) {
57
- parts.push(
58
- `Invalid image alias value(s): ${
59
- invalidValues
60
- .map((kv) => JSON.stringify(kv))
61
- .join(', ')
62
- } (must match ${IMAGE_ALIAS_VALUE_REGEX})`,
63
- )
64
- }
65
-
66
- throw new Error(parts.join(' | '))
67
- }
68
- }
@@ -1,4 +1,4 @@
1
- import * as z from 'zod'
1
+ import z from 'zod/v4'
2
2
 
3
3
  // Relative path (no leading slash, drive letter, or protocol)
4
4
  // eslint-disable-next-line no-useless-escape
@@ -13,85 +13,47 @@ const IncludePathValue = z
13
13
  'Include path must be a relative path (no leading slash, drive letter, or protocol)',
14
14
  )
15
15
 
16
- export const IncludeConfigSchema = z.object({
17
- paths: z.array(IncludePathValue)
18
- .min(1, 'Include paths cannot be empty')
19
- .meta({
20
- description: [
21
- 'Additional relative directory paths to include LikeC4 source files from, searched recursively.',
22
- 'Paths are relative to the project folder (the folder containing this config file).',
23
- 'Example: ["../shared", "../common/specs"]',
24
- ].join('\n'),
25
- }),
26
- maxDepth: z.number()
27
- .int()
28
- .min(1)
29
- .max(20)
30
- .default(3)
31
- .meta({
32
- description: [
33
- 'Maximum directory depth to scan when searching for .c4 files in include paths.',
34
- 'Prevents excessive scanning of deeply nested directories.',
35
- 'Default: 3',
36
- ].join('\n'),
37
- }),
38
- fileThreshold: z.number()
39
- .int()
40
- .min(1)
41
- .default(30)
42
- .meta({
43
- description: [
44
- 'Maximum number of files to load from include paths before warning.',
45
- 'Helps identify performance issues from accidentally including large directories.',
46
- 'Default: 30',
47
- ].join('\n'),
48
- }),
49
- })
50
- .meta({
51
- id: 'include-config',
52
- description: 'Configuration for including additional LikeC4 source files',
16
+ export const IncludeSchema = z
17
+ .strictObject({
18
+ paths: z.array(IncludePathValue)
19
+ .meta({
20
+ description: [
21
+ 'Additional relative directory paths to include LikeC4 source files from, searched recursively.',
22
+ 'Paths are relative to the project folder (the folder containing this config file).',
23
+ 'Example: ["../shared", "../common/specs"]',
24
+ ].join('\n'),
25
+ }),
26
+ maxDepth: z.number()
27
+ .int()
28
+ .min(1)
29
+ .max(20)
30
+ .default(3)
31
+ .meta({
32
+ description: [
33
+ 'Maximum directory depth to scan when searching for .c4 files in include paths.',
34
+ 'Prevents excessive scanning of deeply nested directories.',
35
+ 'Default: 3',
36
+ ].join('\n'),
37
+ }),
38
+ fileThreshold: z.number()
39
+ .int()
40
+ .min(1)
41
+ .max(10000)
42
+ .default(30)
43
+ .meta({
44
+ description: [
45
+ 'Maximum number of files to load from include paths before warning.',
46
+ 'Helps identify performance issues from accidentally including large directories.',
47
+ 'Default: 30',
48
+ ].join('\n'),
49
+ }),
53
50
  })
54
-
55
- export type IncludeConfig = z.infer<typeof IncludeConfigSchema>
56
-
57
- export const IncludeSchema = IncludeConfigSchema
58
- .optional()
59
51
  .meta({
52
+ id: 'include-config',
60
53
  description: [
61
54
  'Configuration for including additional LikeC4 source files from other directories.',
62
55
  'Example: { "paths": ["../shared", "../common/specs"], "maxDepth": 5, "fileThreshold": 50 }',
63
56
  ].join('\n'),
64
57
  })
65
58
 
66
- type LikeC4IncludeConfig = z.infer<typeof IncludeSchema>
67
-
68
- export function normalizeIncludeConfig(include?: LikeC4IncludeConfig): IncludeConfig {
69
- if (!include) {
70
- return { paths: [], maxDepth: 3, fileThreshold: 30 }
71
- }
72
- return include
73
- }
74
-
75
- export function validateIncludePaths(include?: LikeC4IncludeConfig) {
76
- if (!include?.paths) {
77
- return
78
- }
79
-
80
- const invalidPaths: string[] = []
81
-
82
- for (const path of include.paths) {
83
- if (!RELATIVE_PATH_REGEX.test(path)) {
84
- invalidPaths.push(path)
85
- }
86
- }
87
-
88
- if (invalidPaths.length) {
89
- throw new Error(
90
- `Invalid include path(s): ${
91
- invalidPaths
92
- .map((p) => JSON.stringify(p))
93
- .join(', ')
94
- } (must be relative paths without leading slash, drive letter, or protocol)`,
95
- )
96
- }
97
- }
59
+ export type IncludeConfig = z.infer<typeof IncludeSchema>