@sntlr/registry-shell 1.0.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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/adapter/custom.d.ts +47 -0
  4. package/dist/adapter/custom.js +53 -0
  5. package/dist/adapter/custom.js.map +1 -0
  6. package/dist/adapter/default.d.ts +40 -0
  7. package/dist/adapter/default.js +202 -0
  8. package/dist/adapter/default.js.map +1 -0
  9. package/dist/cli/build.d.ts +1 -0
  10. package/dist/cli/build.js +31 -0
  11. package/dist/cli/build.js.map +1 -0
  12. package/dist/cli/dev.d.ts +1 -0
  13. package/dist/cli/dev.js +26 -0
  14. package/dist/cli/dev.js.map +1 -0
  15. package/dist/cli/index.d.ts +12 -0
  16. package/dist/cli/index.js +49 -0
  17. package/dist/cli/index.js.map +1 -0
  18. package/dist/cli/init.d.ts +1 -0
  19. package/dist/cli/init.js +70 -0
  20. package/dist/cli/init.js.map +1 -0
  21. package/dist/cli/shared.d.ts +33 -0
  22. package/dist/cli/shared.js +278 -0
  23. package/dist/cli/shared.js.map +1 -0
  24. package/dist/cli/start.d.ts +1 -0
  25. package/dist/cli/start.js +24 -0
  26. package/dist/cli/start.js.map +1 -0
  27. package/dist/config-loader.d.ts +49 -0
  28. package/dist/config-loader.js +140 -0
  29. package/dist/define-config.d.ts +188 -0
  30. package/dist/define-config.js +21 -0
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.js +9 -0
  33. package/package.json +124 -0
  34. package/src/adapter/custom.ts +90 -0
  35. package/src/adapter/default.ts +241 -0
  36. package/src/cli/build.ts +38 -0
  37. package/src/cli/dev.ts +38 -0
  38. package/src/cli/index.ts +52 -0
  39. package/src/cli/init.ts +76 -0
  40. package/src/cli/shared.ts +306 -0
  41. package/src/cli/start.ts +28 -0
  42. package/src/config-loader.ts +190 -0
  43. package/src/define-config.ts +206 -0
  44. package/src/index.ts +17 -0
  45. package/src/next-app/app/[...asset]/route.ts +81 -0
  46. package/src/next-app/app/_user-global.css +6 -0
  47. package/src/next-app/app/_user-sources.css +9 -0
  48. package/src/next-app/app/a11y/[name]/route.ts +19 -0
  49. package/src/next-app/app/api/search-index/route.ts +19 -0
  50. package/src/next-app/app/components/[name]/page.tsx +61 -0
  51. package/src/next-app/app/components/layout.tsx +18 -0
  52. package/src/next-app/app/docs/[slug]/page.tsx +53 -0
  53. package/src/next-app/app/docs/layout.tsx +18 -0
  54. package/src/next-app/app/globals.css +329 -0
  55. package/src/next-app/app/layout.tsx +102 -0
  56. package/src/next-app/app/page.tsx +9 -0
  57. package/src/next-app/app/preview-snapshot/[name]/page.tsx +20 -0
  58. package/src/next-app/app/preview-snapshot/layout.tsx +17 -0
  59. package/src/next-app/app/props/[name]/route.ts +19 -0
  60. package/src/next-app/app/r/[name]/route.ts +14 -0
  61. package/src/next-app/app/tests/[name]/route.ts +19 -0
  62. package/src/next-app/components/a11y-info.tsx +287 -0
  63. package/src/next-app/components/a11y-provider.tsx +39 -0
  64. package/src/next-app/components/component-breadcrumb.tsx +55 -0
  65. package/src/next-app/components/component-icon.tsx +140 -0
  66. package/src/next-app/components/component-preview.tsx +13 -0
  67. package/src/next-app/components/component-tabs.tsx +209 -0
  68. package/src/next-app/components/docs-toc.tsx +86 -0
  69. package/src/next-app/components/global-mobile-sidebar.tsx +35 -0
  70. package/src/next-app/components/header.tsx +188 -0
  71. package/src/next-app/components/heading-anchor.tsx +52 -0
  72. package/src/next-app/components/homepage-demo.tsx +180 -0
  73. package/src/next-app/components/locale-toggle.tsx +35 -0
  74. package/src/next-app/components/localized-mdx-client.tsx +14 -0
  75. package/src/next-app/components/localized-mdx.tsx +27 -0
  76. package/src/next-app/components/mobile-sidebar.tsx +22 -0
  77. package/src/next-app/components/nav-data-provider.tsx +37 -0
  78. package/src/next-app/components/navigation-progress.tsx +62 -0
  79. package/src/next-app/components/preview-canvas.tsx +368 -0
  80. package/src/next-app/components/preview-controls.tsx +94 -0
  81. package/src/next-app/components/preview-layout.tsx +218 -0
  82. package/src/next-app/components/props-table.tsx +134 -0
  83. package/src/next-app/components/resizable-preview.tsx +101 -0
  84. package/src/next-app/components/search.tsx +177 -0
  85. package/src/next-app/components/settings-modal.tsx +98 -0
  86. package/src/next-app/components/shell-ui/accordion.tsx +70 -0
  87. package/src/next-app/components/shell-ui/backdrop.tsx +29 -0
  88. package/src/next-app/components/shell-ui/badge.tsx +55 -0
  89. package/src/next-app/components/shell-ui/breadcrumb.tsx +120 -0
  90. package/src/next-app/components/shell-ui/button.tsx +64 -0
  91. package/src/next-app/components/shell-ui/card.tsx +127 -0
  92. package/src/next-app/components/shell-ui/checkbox.tsx +33 -0
  93. package/src/next-app/components/shell-ui/dialog.tsx +171 -0
  94. package/src/next-app/components/shell-ui/empty-state.tsx +66 -0
  95. package/src/next-app/components/shell-ui/input.tsx +27 -0
  96. package/src/next-app/components/shell-ui/kbd.tsx +30 -0
  97. package/src/next-app/components/shell-ui/label.tsx +25 -0
  98. package/src/next-app/components/shell-ui/select.tsx +204 -0
  99. package/src/next-app/components/shell-ui/separator.tsx +32 -0
  100. package/src/next-app/components/shell-ui/skeleton.tsx +18 -0
  101. package/src/next-app/components/shell-ui/table.tsx +124 -0
  102. package/src/next-app/components/shell-ui/tabs.tsx +102 -0
  103. package/src/next-app/components/shell-ui/toggle.tsx +56 -0
  104. package/src/next-app/components/sidebar-layout.tsx +37 -0
  105. package/src/next-app/components/sidebar-provider.tsx +75 -0
  106. package/src/next-app/components/sidebar.tsx +222 -0
  107. package/src/next-app/components/snapshot-preview.tsx +28 -0
  108. package/src/next-app/components/test-info.tsx +155 -0
  109. package/src/next-app/components/theme-provider.tsx +16 -0
  110. package/src/next-app/components/theme-toggle.tsx +21 -0
  111. package/src/next-app/components/translated-text.tsx +8 -0
  112. package/src/next-app/fallback/homepage.tsx +112 -0
  113. package/src/next-app/fallback/previews.ts +17 -0
  114. package/src/next-app/hooks/use-active-section.ts +23 -0
  115. package/src/next-app/hooks/use-controls.ts +72 -0
  116. package/src/next-app/hooks/use-mobile.ts +19 -0
  117. package/src/next-app/lib/branding.ts +52 -0
  118. package/src/next-app/lib/components-nav.ts +8 -0
  119. package/src/next-app/lib/docs.ts +16 -0
  120. package/src/next-app/lib/github.ts +38 -0
  121. package/src/next-app/lib/i18n.tsx +630 -0
  122. package/src/next-app/lib/locales.ts +17 -0
  123. package/src/next-app/lib/preview-loader.ts +7 -0
  124. package/src/next-app/lib/registry-adapter.ts +199 -0
  125. package/src/next-app/lib/utils.ts +6 -0
  126. package/src/next-app/next-env.d.ts +6 -0
  127. package/src/next-app/next.config.ts +101 -0
  128. package/src/next-app/postcss.config.mjs +7 -0
  129. package/src/next-app/public/favicon.ico +0 -0
  130. package/src/next-app/public/favicon_dark.svg +3 -0
  131. package/src/next-app/public/favicon_light.svg +3 -0
  132. package/src/next-app/registry.config.ts +50 -0
  133. package/src/next-app/tsconfig.json +29 -0
  134. package/src/next-app/user-aliases.d.ts +17 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Helpers shared across CLI commands: locating the shell's bundled Next app,
3
+ * finding the user's config file, and injecting the env vars the Next app
4
+ * reads at boot.
5
+ */
6
+ import fs from "node:fs"
7
+ import path from "node:path"
8
+ import { createRequire } from "node:module"
9
+ import { fileURLToPath } from "node:url"
10
+ import { createJiti } from "jiti"
11
+ import type { ShellConfig } from "../define-config.js"
12
+
13
+ /**
14
+ * Absolute path to the Next.js CLI binary shipped with the shell package.
15
+ * Resolves against the shell's own node_modules so a consumer doesn't need
16
+ * to declare Next as a direct dep — they depend on `@sntlr/registry-shell`
17
+ * and transitively get Next.
18
+ */
19
+ const requireFromHere = createRequire(import.meta.url)
20
+ export const NEXT_BIN = requireFromHere.resolve("next/dist/bin/next")
21
+
22
+ // `path` / `fs` already imported above; ensure both stay usable below.
23
+
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
25
+
26
+ const CONFIG_FILE_CANDIDATES = [
27
+ "registry-shell.config.ts",
28
+ "registry-shell.config.js",
29
+ "registry-shell.config.mjs",
30
+ ]
31
+
32
+ /** Walk upward from cwd looking for a config file. Returns null if none. */
33
+ export function findConfigFile(cwd: string = process.cwd()): string | null {
34
+ let dir = cwd
35
+ while (true) {
36
+ for (const name of CONFIG_FILE_CANDIDATES) {
37
+ const candidate = path.join(dir, name)
38
+ if (fs.existsSync(candidate)) return candidate
39
+ }
40
+ const parent = path.dirname(dir)
41
+ if (parent === dir) return null
42
+ dir = parent
43
+ }
44
+ }
45
+
46
+ /** Absolute path to the Next.js app bundled inside the shell package. */
47
+ export function nextAppDir(): string {
48
+ // When running from dist (published), this file is at dist/cli/shared.js.
49
+ // When running from source via tsx, it's at src/cli/shared.ts.
50
+ // In both cases, next-app lives at ../next-app relative to the cli dir.
51
+ const distNextApp = path.resolve(HERE, "../next-app")
52
+ if (fs.existsSync(distNextApp)) return distNextApp
53
+
54
+ const srcNextApp = path.resolve(HERE, "../../src/next-app")
55
+ if (fs.existsSync(srcNextApp)) return srcNextApp
56
+
57
+ throw new Error(
58
+ `[registry-shell] Couldn't locate the bundled Next app. Looked in:\n ${distNextApp}\n ${srcNextApp}`,
59
+ )
60
+ }
61
+
62
+ export interface LoadedConfig {
63
+ configPath: string
64
+ root: string
65
+ config: ShellConfig
66
+ }
67
+
68
+ /**
69
+ * Read + parse the user's config file via jiti (zero-build TS). Returns null
70
+ * when no config is found (shell-only mode).
71
+ */
72
+ export function loadUserConfig(): LoadedConfig | null {
73
+ const configPath = findConfigFile()
74
+ if (!configPath) return null
75
+
76
+ const jiti = createJiti(import.meta.url, { interopDefault: true })
77
+ const loaded = jiti(configPath) as unknown
78
+ const config = (
79
+ loaded && typeof loaded === "object" && "default" in loaded
80
+ ? (loaded as { default: ShellConfig }).default
81
+ : (loaded as ShellConfig)
82
+ )
83
+
84
+ if (!config?.branding) {
85
+ throw new Error(
86
+ `[registry-shell] Invalid config at ${configPath}: missing required \`branding\`.`,
87
+ )
88
+ }
89
+
90
+ return {
91
+ configPath,
92
+ root: path.dirname(configPath),
93
+ config,
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Generate a CSS file containing `@source` directives that tell Tailwind v4
99
+ * to scan the user's component/block/doc files for utility classes. Without
100
+ * this, classes used only in the user's files don't end up in the bundled
101
+ * stylesheet. Rewritten every time the CLI boots so path changes in the
102
+ * user's config take effect immediately.
103
+ */
104
+ /**
105
+ * The shell's `.next/` build output is keyed to the active config. When the
106
+ * user switches from a wired registry to shell-only (or swaps registries
107
+ * altogether), the old build has eager references to files that may no
108
+ * longer resolve. Detect that by stamping the current mode and clearing
109
+ * `.next/` when it changes.
110
+ */
111
+ export function clearStaleNextCacheIfModeChanged(loaded: LoadedConfig | null): void {
112
+ const nextApp = nextAppDir()
113
+ const nextDir = path.join(nextApp, ".next")
114
+ const stampPath = path.join(nextApp, ".registry-shell-mode")
115
+ const currentMode = loaded?.configPath ?? "<shell-only>"
116
+
117
+ let previousMode = "<never>"
118
+ if (fs.existsSync(stampPath)) {
119
+ try {
120
+ previousMode = fs.readFileSync(stampPath, "utf-8").trim()
121
+ } catch {
122
+ /* ignore */
123
+ }
124
+ }
125
+
126
+ if (fs.existsSync(nextDir) && previousMode !== currentMode) {
127
+ console.log(
128
+ `[registry-shell] Mode changed (${previousMode} → ${currentMode}) — clearing .next cache.`,
129
+ )
130
+ // Windows can hold file locks (antivirus, IDE indexers) for a moment
131
+ // after the previous Next process exits. Retry a few times; if the
132
+ // directory still won't remove, clear its contents in place instead.
133
+ const tryRemove = (attempt = 0): boolean => {
134
+ try {
135
+ fs.rmSync(nextDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
136
+ return true
137
+ } catch {
138
+ if (attempt < 5) return tryRemove(attempt + 1)
139
+ return false
140
+ }
141
+ }
142
+ if (!tryRemove()) {
143
+ try {
144
+ for (const entry of fs.readdirSync(nextDir)) {
145
+ fs.rmSync(path.join(nextDir, entry), {
146
+ recursive: true,
147
+ force: true,
148
+ maxRetries: 5,
149
+ retryDelay: 200,
150
+ })
151
+ }
152
+ console.log("[registry-shell] Directory locked — cleared contents instead.")
153
+ } catch (err) {
154
+ console.warn(
155
+ `[registry-shell] Couldn't clear .next cache cleanly (${(err as Error).message}). ` +
156
+ `Continuing; you may see stale-build warnings.`,
157
+ )
158
+ }
159
+ }
160
+ }
161
+
162
+ fs.writeFileSync(stampPath, currentMode + "\n", "utf-8")
163
+ }
164
+
165
+ export function writeUserSourcesCss(loaded: LoadedConfig | null): void {
166
+ const nextApp = nextAppDir()
167
+ const appDir = path.join(nextApp, "app")
168
+ const sourcesTarget = path.join(appDir, "_user-sources.css")
169
+ const globalTarget = path.join(appDir, "_user-global.css")
170
+
171
+ // Tailwind 4 resolves `@source` paths relative to the CSS file on disk and
172
+ // handles platform-specific separators. Use relative paths so Windows
173
+ // drive-letter handling doesn't trip the scanner.
174
+ const rel = (abs: string) => {
175
+ const r = path.relative(appDir, abs).replace(/\\/g, "/")
176
+ return r.startsWith(".") ? r : `./${r}`
177
+ }
178
+
179
+ // ── _user-sources.css — `@source` directives only ──────────────────
180
+ // Imported near the TOP of globals.css so Tailwind's class scanner
181
+ // picks up utility usage from the listed dirs.
182
+ const sources = ["/* Auto-generated by @sntlr/registry-shell. Do not edit. */"]
183
+ sources.push(`@source "${rel(path.join(nextApp, "app"))}";`)
184
+ sources.push(`@source "${rel(path.join(nextApp, "components"))}";`)
185
+ sources.push(`@source "${rel(path.join(nextApp, "lib"))}";`)
186
+ sources.push(`@source "${rel(path.join(nextApp, "hooks"))}";`)
187
+ sources.push(`@source "${rel(path.join(nextApp, "fallback"))}";`)
188
+
189
+ if (loaded) {
190
+ const paths = loaded.config.paths ?? {}
191
+ const resolve = (r: string | undefined, fallback: string) =>
192
+ path.resolve(loaded.root, r ?? fallback)
193
+
194
+ sources.push(`@source "${rel(resolve(paths.components, "components/ui"))}";`)
195
+ sources.push(`@source "${rel(resolve(paths.blocks, "registry/new-york/blocks"))}";`)
196
+ sources.push(
197
+ `@source "${rel(resolve(paths.previews?.replace(/\/index\.[tj]sx?$/, ""), "components/previews"))}";`,
198
+ )
199
+ if (loaded.config.homePage) {
200
+ sources.push(`@source "${rel(resolve(loaded.config.homePage, ""))}";`)
201
+ }
202
+ }
203
+ fs.writeFileSync(sourcesTarget, sources.join("\n") + "\n", "utf-8")
204
+
205
+ // ── _user-global.css — user's extra theme/tokens ──────────────────
206
+ // Imported at the BOTTOM of globals.css so `:root { --primary: ... }`
207
+ // style overrides win the cascade over the shell's defaults. Absent
208
+ // when paths.globalCss is not configured (file is written as a no-op
209
+ // so the `@import` in globals.css never 404s).
210
+ const globalLines = ["/* Auto-generated by @sntlr/registry-shell. Do not edit. */"]
211
+ if (loaded) {
212
+ const userGlobal = loaded.config.paths?.globalCss
213
+ if (userGlobal) {
214
+ const abs = path.resolve(loaded.root, userGlobal)
215
+ if (!fs.existsSync(abs)) {
216
+ console.warn(
217
+ `[registry-shell] paths.globalCss points at ${abs} but the file doesn't exist — skipping.`,
218
+ )
219
+ } else {
220
+ globalLines.push(`@import "${rel(abs)}";`)
221
+ }
222
+ }
223
+ }
224
+ fs.writeFileSync(globalTarget, globalLines.join("\n") + "\n", "utf-8")
225
+ }
226
+
227
+ /**
228
+ * Build the env-var bag the Next app reads at startup. The CLI spreads this
229
+ * into child `next` processes.
230
+ */
231
+ /**
232
+ * Resolve the locale list the client's locale toggle should offer. Uses the
233
+ * explicit `locales` array if provided, otherwise auto-scans subfolders of
234
+ * the registry's docs path. Mirrors the logic in the server config-loader
235
+ * but runs client-side via inlined env vars.
236
+ */
237
+ function resolveLocaleList(root: string, config: ShellConfig): string[] {
238
+ if (!config.multilocale) return []
239
+ if (config.locales && config.locales.length > 0) return [...config.locales]
240
+
241
+ const docsAbs = path.resolve(root, config.paths?.docs ?? "content/docs")
242
+ if (!fs.existsSync(docsAbs)) {
243
+ return config.defaultLocale ? [config.defaultLocale] : []
244
+ }
245
+ const found = fs
246
+ .readdirSync(docsAbs, { withFileTypes: true })
247
+ .filter((d) => d.isDirectory())
248
+ .map((d) => d.name)
249
+
250
+ if (config.defaultLocale && found.includes(config.defaultLocale)) {
251
+ return [config.defaultLocale, ...found.filter((l) => l !== config.defaultLocale)]
252
+ }
253
+ return found
254
+ }
255
+
256
+ export function buildEnvVars(loaded: LoadedConfig | null): Record<string, string> {
257
+ // Always set SHELL_APP_ROOT so the Next app can resolve its own bundled
258
+ // files (fallbacks, globals.css) independently of process.cwd().
259
+ const base: Record<string, string> = { SHELL_APP_ROOT: nextAppDir() }
260
+ if (!loaded) return base
261
+
262
+ const { configPath, root, config } = loaded
263
+ const b = config.branding
264
+
265
+ const env: Record<string, string> = {
266
+ ...base,
267
+ USER_CONFIG_PATH: configPath,
268
+ USER_REGISTRY_ROOT: root,
269
+ NEXT_PUBLIC_SHELL_SITE_NAME: b.siteName,
270
+ NEXT_PUBLIC_SHELL_SHORT_NAME: b.shortName,
271
+ }
272
+
273
+ if (b.siteUrl) env.NEXT_PUBLIC_SHELL_SITE_URL = b.siteUrl
274
+ if (b.description) env.NEXT_PUBLIC_SHELL_DESCRIPTION = b.description
275
+ if (b.ogImage) env.NEXT_PUBLIC_SHELL_OG_IMAGE = b.ogImage
276
+ if (b.twitterHandle) env.NEXT_PUBLIC_SHELL_TWITTER_HANDLE = b.twitterHandle
277
+ if (b.github?.owner) env.NEXT_PUBLIC_SHELL_GITHUB_OWNER = b.github.owner
278
+ if (b.github?.repo) env.NEXT_PUBLIC_SHELL_GITHUB_REPO = b.github.repo
279
+ if (b.github?.label) env.NEXT_PUBLIC_SHELL_GITHUB_LABEL = b.github.label
280
+ if (b.github && b.github.showStars === false) {
281
+ env.NEXT_PUBLIC_SHELL_GITHUB_SHOW_STARS = "false"
282
+ }
283
+ if (b.logoAlt) env.NEXT_PUBLIC_SHELL_LOGO_ALT = b.logoAlt
284
+ if (b.faviconDark) env.NEXT_PUBLIC_SHELL_FAVICON_DARK = b.faviconDark
285
+ if (b.faviconLight) env.NEXT_PUBLIC_SHELL_FAVICON_LIGHT = b.faviconLight
286
+ if (b.faviconIco) env.NEXT_PUBLIC_SHELL_FAVICON_ICO = b.faviconIco
287
+
288
+ if (config.homePage) env.USER_HOMEPAGE_PATH = config.homePage
289
+ if (config.installCommandTemplate !== undefined) {
290
+ env.NEXT_PUBLIC_SHELL_INSTALL_CMD = config.installCommandTemplate
291
+ }
292
+ if (config.transpilePackages && config.transpilePackages.length > 0) {
293
+ env.USER_TRANSPILE_PACKAGES = config.transpilePackages.join(",")
294
+ }
295
+
296
+ // Multilocale signalling for the locale toggle. Resolves the toggle's
297
+ // locale set from explicit config or auto-scan of doc subfolders so the
298
+ // client has a complete list at build time.
299
+ if (config.multilocale && config.defaultLocale) {
300
+ env.NEXT_PUBLIC_SHELL_DEFAULT_LOCALE = config.defaultLocale
301
+ const locales = resolveLocaleList(loaded.root, config)
302
+ env.NEXT_PUBLIC_SHELL_LOCALES = locales.join(",")
303
+ }
304
+
305
+ return env
306
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `registry-shell start` — run `next start` for a prior `registry-shell build`.
3
+ *
4
+ * Mirrors build.ts: passes `USER_DIST_DIR` pointing at `<user-project>/.next`
5
+ * so `next start` reads the build output from where build.ts wrote it.
6
+ */
7
+ import path from "node:path"
8
+ import { spawn } from "node:child_process"
9
+ import { NEXT_BIN, buildEnvVars, loadUserConfig, nextAppDir } from "./shared.js"
10
+
11
+ export async function run(args: string[]): Promise<void> {
12
+ const loaded = loadUserConfig()
13
+ const userDistDir = loaded
14
+ ? path.resolve(loaded.root, loaded.config.paths?.buildOutput ?? ".next")
15
+ : undefined
16
+ const env = {
17
+ ...process.env,
18
+ ...buildEnvVars(loaded),
19
+ ...(userDistDir ? { USER_DIST_DIR: userDistDir } : {}),
20
+ }
21
+ const portArgs = loaded?.config.port ? ["-p", String(loaded.config.port)] : []
22
+ const child = spawn(
23
+ process.execPath,
24
+ [NEXT_BIN, "start", nextAppDir(), ...portArgs, ...args],
25
+ { stdio: "inherit", env },
26
+ )
27
+ child.on("exit", (code) => process.exit(code ?? 0))
28
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Loads the user's `registry-shell.config.ts` at server boot. Uses `jiti` so
3
+ * the user doesn't need a build step or TS tooling of their own — the shell
4
+ * parses TS on the fly.
5
+ *
6
+ * Called once at Next.js server startup (see src/next-app/registry.config.ts).
7
+ * The returned `ResolvedShellConfig` contains absolute paths and defaults
8
+ * applied.
9
+ */
10
+ import "server-only"
11
+ import path from "node:path"
12
+ import fs from "node:fs"
13
+ import { createJiti } from "jiti"
14
+ import type { ShellConfig, ShellPaths, BrandingConfig } from "./define-config.js"
15
+
16
+ // `skipBlocks`, `globalCss`, and `buildOutput` are excluded from the
17
+ // "must have a default" shape: skipBlocks is always an array (empty
18
+ // default); globalCss is purely opt-in; buildOutput is read by the CLI
19
+ // directly (with its own `.next` default) and doesn't flow through the
20
+ // server-side resolver.
21
+ const DEFAULT_PATHS: Required<
22
+ Omit<ShellPaths, "skipBlocks" | "globalCss" | "buildOutput">
23
+ > & { skipBlocks: string[] } = {
24
+ components: "components/ui",
25
+ blocks: "registry/new-york/blocks",
26
+ previews: "components/previews/index.ts",
27
+ docs: "content/docs",
28
+ registryJson: "public/r",
29
+ a11y: "public/a11y",
30
+ tests: "public/tests",
31
+ props: "public/props",
32
+ skipBlocks: [],
33
+ }
34
+
35
+ const DEFAULT_BRANDING: BrandingConfig = {
36
+ siteName: "UI Registry",
37
+ shortName: "UI",
38
+ siteUrl: "",
39
+ logoAlt: "UI",
40
+ faviconDark: "/favicon_dark.svg",
41
+ faviconLight: "/favicon_light.svg",
42
+ faviconIco: "/favicon.ico",
43
+ }
44
+
45
+ export interface ResolvedShellConfig {
46
+ /** Absolute path to the user's registry root (dir containing the config). */
47
+ root: string
48
+ /** Absolute path to the config file itself. */
49
+ configPath: string
50
+ branding: Required<BrandingConfig>
51
+ /** All paths resolved to absolute locations. */
52
+ paths: {
53
+ components: string
54
+ blocks: string
55
+ previews: string
56
+ docs: string
57
+ registryJson: string
58
+ a11y: string
59
+ tests: string
60
+ props: string
61
+ skipBlocks: Set<string>
62
+ /** Absolute path to user's extra global CSS, or null when not configured. */
63
+ globalCss: string | null
64
+ }
65
+ /** Absolute path to a custom homepage module, or null. */
66
+ homePage: string | null
67
+ /** Absolute path to a custom adapter module, or null. */
68
+ adapter: string | null
69
+ extraTranslations: Record<string, Record<string, string>>
70
+ /** When true, docs live under per-locale subfolders. */
71
+ multilocale: boolean
72
+ /** Locale subfolder containing the canonical doc set. Empty when multilocale is off. */
73
+ defaultLocale: string
74
+ /** Explicit locale list (resolved — may be empty in single-locale mode). */
75
+ locales: string[]
76
+ }
77
+
78
+ /**
79
+ * Read env vars set by the CLI, load the config file, resolve paths. Returns
80
+ * `null` when no config is set (shell-only dev mode) — the Next app falls
81
+ * back to built-in shell docs.
82
+ */
83
+ export function loadResolvedConfig(): ResolvedShellConfig | null {
84
+ const configPath = process.env.USER_CONFIG_PATH
85
+ const root = process.env.USER_REGISTRY_ROOT
86
+ if (!configPath || !root) return null
87
+
88
+ if (!fs.existsSync(configPath)) {
89
+ throw new Error(
90
+ `[registry-shell] Config file not found: ${configPath}. ` +
91
+ `Check USER_CONFIG_PATH.`,
92
+ )
93
+ }
94
+
95
+ const jiti = createJiti(import.meta.url, { interopDefault: true })
96
+ const loaded = jiti(configPath) as unknown
97
+ const config = extractDefault(loaded) as ShellConfig
98
+
99
+ if (!config?.branding) {
100
+ throw new Error(
101
+ `[registry-shell] Invalid config at ${configPath}: missing required \`branding\`.`,
102
+ )
103
+ }
104
+ if (config.multilocale && !config.defaultLocale) {
105
+ throw new Error(
106
+ `[registry-shell] Invalid config at ${configPath}: \`multilocale\` is true but \`defaultLocale\` is missing.`,
107
+ )
108
+ }
109
+
110
+ const rootAbs = path.resolve(root)
111
+ const cfgPaths = config.paths ?? {}
112
+
113
+ return {
114
+ root: rootAbs,
115
+ configPath,
116
+ branding: applyBrandingDefaults(config.branding),
117
+ paths: {
118
+ components: path.resolve(rootAbs, cfgPaths.components ?? DEFAULT_PATHS.components),
119
+ blocks: path.resolve(rootAbs, cfgPaths.blocks ?? DEFAULT_PATHS.blocks),
120
+ previews: path.resolve(rootAbs, cfgPaths.previews ?? DEFAULT_PATHS.previews),
121
+ docs: path.resolve(rootAbs, cfgPaths.docs ?? DEFAULT_PATHS.docs),
122
+ registryJson: path.resolve(rootAbs, cfgPaths.registryJson ?? DEFAULT_PATHS.registryJson),
123
+ a11y: path.resolve(rootAbs, cfgPaths.a11y ?? DEFAULT_PATHS.a11y),
124
+ tests: path.resolve(rootAbs, cfgPaths.tests ?? DEFAULT_PATHS.tests),
125
+ props: path.resolve(rootAbs, cfgPaths.props ?? DEFAULT_PATHS.props),
126
+ skipBlocks: new Set(cfgPaths.skipBlocks ?? []),
127
+ globalCss: cfgPaths.globalCss ? path.resolve(rootAbs, cfgPaths.globalCss) : null,
128
+ },
129
+ homePage: config.homePage ? path.resolve(rootAbs, config.homePage) : null,
130
+ adapter: config.adapter ? path.resolve(rootAbs, config.adapter) : null,
131
+ extraTranslations: config.extraTranslations ?? {},
132
+ multilocale: Boolean(config.multilocale),
133
+ defaultLocale: config.defaultLocale ?? "",
134
+ locales: resolveLocales(rootAbs, cfgPaths.docs ?? DEFAULT_PATHS.docs, config),
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Resolve the locale list for the locale toggle. In single-locale mode we
140
+ * return an empty array (the toggle hides itself). In multilocale mode we
141
+ * use the explicit `locales` config if present, otherwise auto-scan
142
+ * subfolders under `paths.docs`.
143
+ */
144
+ function resolveLocales(rootAbs: string, docsRel: string, config: ShellConfig): string[] {
145
+ if (!config.multilocale) return []
146
+ if (config.locales && config.locales.length > 0) return [...config.locales]
147
+
148
+ const docsAbs = path.resolve(rootAbs, docsRel)
149
+ if (!fs.existsSync(docsAbs)) return config.defaultLocale ? [config.defaultLocale] : []
150
+ const found = fs
151
+ .readdirSync(docsAbs, { withFileTypes: true })
152
+ .filter((d) => d.isDirectory())
153
+ .map((d) => d.name)
154
+
155
+ // Put the default locale first so the toggle cycles predictably.
156
+ if (config.defaultLocale && found.includes(config.defaultLocale)) {
157
+ return [config.defaultLocale, ...found.filter((l) => l !== config.defaultLocale)]
158
+ }
159
+ return found
160
+ }
161
+
162
+ function extractDefault(loaded: unknown): unknown {
163
+ if (loaded && typeof loaded === "object" && "default" in loaded) {
164
+ return (loaded as { default: unknown }).default
165
+ }
166
+ return loaded
167
+ }
168
+
169
+ function applyBrandingDefaults(b: BrandingConfig): Required<BrandingConfig> {
170
+ return {
171
+ siteName: b.siteName,
172
+ shortName: b.shortName,
173
+ siteUrl: b.siteUrl ?? "",
174
+ description: b.description ?? "",
175
+ ogImage: b.ogImage ?? "",
176
+ twitterHandle: b.twitterHandle ?? "",
177
+ github: b.github
178
+ ? {
179
+ owner: b.github.owner,
180
+ repo: b.github.repo,
181
+ label: b.github.label ?? "Github",
182
+ showStars: b.github.showStars ?? true,
183
+ }
184
+ : { owner: "", repo: "", label: "", showStars: false },
185
+ logoAlt: b.logoAlt ?? b.siteName,
186
+ faviconDark: b.faviconDark ?? DEFAULT_BRANDING.faviconDark!,
187
+ faviconLight: b.faviconLight ?? DEFAULT_BRANDING.faviconLight!,
188
+ faviconIco: b.faviconIco ?? DEFAULT_BRANDING.faviconIco!,
189
+ }
190
+ }