cantip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/app/components/CanvasMount.tsx +62 -0
  4. package/app/components/CodeWrapToggle.tsx +78 -0
  5. package/app/components/FindOnPage.tsx +224 -0
  6. package/app/components/MobileBottomBar.tsx +93 -0
  7. package/app/components/MobileProjectsPanel.tsx +113 -0
  8. package/app/components/PageFloatingMenu.tsx +224 -0
  9. package/app/components/ProjectSwitcher.tsx +124 -0
  10. package/app/components/Search.tsx +930 -0
  11. package/app/components/ShortcutsHelp.tsx +113 -0
  12. package/app/components/Sidebar.tsx +1049 -0
  13. package/app/components/TabBar.tsx +227 -0
  14. package/app/components/Toc.tsx +129 -0
  15. package/app/components/TopBar.tsx +74 -0
  16. package/app/components/theme-toggle.tsx +71 -0
  17. package/app/components/ui/button.tsx +56 -0
  18. package/app/components/ui/card.tsx +55 -0
  19. package/app/components/ui/dropdown-menu.tsx +156 -0
  20. package/app/components/ui/input.tsx +21 -0
  21. package/app/entry.client.tsx +12 -0
  22. package/app/entry.server.tsx +155 -0
  23. package/app/generated/site.ts +19 -0
  24. package/app/generated/slots.ts +10 -0
  25. package/app/generated/theme.generated.css +60 -0
  26. package/app/lib/config/config.server.ts +50 -0
  27. package/app/lib/config/defaults.ts +120 -0
  28. package/app/lib/config/load.ts +82 -0
  29. package/app/lib/config/schema.ts +131 -0
  30. package/app/lib/config/site.ts +43 -0
  31. package/app/lib/content.server.ts +105 -0
  32. package/app/lib/projects.ts +86 -0
  33. package/app/lib/sidebar.server.ts +113 -0
  34. package/app/lib/site.ts +27 -0
  35. package/app/lib/slots.tsx +33 -0
  36. package/app/lib/tabs.tsx +128 -0
  37. package/app/lib/useKeyboardShortcuts.ts +149 -0
  38. package/app/lib/utils.ts +17 -0
  39. package/app/root.tsx +171 -0
  40. package/app/routes/$.tsx +158 -0
  41. package/app/routes/_index.tsx +60 -0
  42. package/app/styles/app.css +461 -0
  43. package/app/styles/obsidian.css +83 -0
  44. package/app/styles/tailwind.css +227 -0
  45. package/cli.js +119 -0
  46. package/components.json +21 -0
  47. package/dist/config.mjs +87 -0
  48. package/dist/generate-content.mjs +1665 -0
  49. package/package.json +112 -0
  50. package/scripts/build-search-index.ts +129 -0
  51. package/scripts/canonical.ts +34 -0
  52. package/scripts/canvas-to-md.ts +73 -0
  53. package/scripts/compile.ts +242 -0
  54. package/scripts/emit-config.ts +163 -0
  55. package/scripts/generate-content.ts +197 -0
  56. package/scripts/obsidian/files.ts +222 -0
  57. package/scripts/obsidian/fs.ts +34 -0
  58. package/scripts/obsidian/generate.ts +36 -0
  59. package/scripts/obsidian/html.ts +17 -0
  60. package/scripts/obsidian/logger.ts +10 -0
  61. package/scripts/obsidian/markdown.ts +56 -0
  62. package/scripts/obsidian/obsidian.ts +229 -0
  63. package/scripts/obsidian/path.ts +60 -0
  64. package/scripts/obsidian/rehype.ts +60 -0
  65. package/scripts/obsidian/remark.ts +712 -0
  66. package/scripts/obsidian/types.ts +31 -0
  67. package/vite.config.ts +62 -0
@@ -0,0 +1,156 @@
1
+ import { createContext, useContext, useEffect, useId, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ import { cn } from '~/lib/utils'
5
+
6
+ /**
7
+ * Lightweight dropdown menu — no extra dependency, mirrors the hand-rolled
8
+ * pattern used across the app (ProjectSwitcher, Sidebar RowMenu). A trigger
9
+ * button toggles a menu rendered in a portal with fixed positioning computed
10
+ * from the trigger rect, so it's never clipped by an ancestor's `overflow`.
11
+ *
12
+ * Closes on outside click, Escape, scroll, or resize. The menu is anchored to a
13
+ * corner of the trigger via `align`:
14
+ * - 'start' : menu's left edge aligns with the trigger's left edge
15
+ * - 'end' : menu's right edge aligns with the trigger's right edge
16
+ *
17
+ * Compose menu contents with <DropdownMenuItem>. `onSelect` fires then the menu
18
+ * closes automatically.
19
+ */
20
+
21
+ interface MenuPos {
22
+ top: number
23
+ /** The CSS `left` for the menu box; combined with `translateX` for `end` align. */
24
+ left: number
25
+ align: 'start' | 'end'
26
+ }
27
+
28
+ /** Lets a <DropdownMenuItem> close its own parent menu after selecting. */
29
+ const MenuCloseContext = createContext<() => void>(() => {})
30
+
31
+ export function DropdownMenu({
32
+ trigger,
33
+ children,
34
+ align = 'start',
35
+ menuClassName,
36
+ className,
37
+ label,
38
+ }: {
39
+ /** Render the trigger; receives the live open state for styling. */
40
+ trigger: (state: { open: boolean }) => React.ReactNode
41
+ children: React.ReactNode
42
+ align?: 'start' | 'end'
43
+ /** Extra classes for the menu box. */
44
+ menuClassName?: string
45
+ /** Classes for the trigger button; a function receives the live open state. */
46
+ className?: string | ((state: { open: boolean }) => string)
47
+ /** Accessible label for the trigger button. */
48
+ label?: string
49
+ }) {
50
+ const [open, setOpen] = useState(false)
51
+ const [pos, setPos] = useState<MenuPos | null>(null)
52
+ const btnRef = useRef<HTMLButtonElement>(null)
53
+ const menuId = useId()
54
+
55
+ useEffect(() => {
56
+ if (!open) return
57
+ const close = () => setOpen(false)
58
+ const onKey = (e: KeyboardEvent) => {
59
+ if (e.key === 'Escape') setOpen(false)
60
+ }
61
+ // Close on any outside interaction; also on scroll/resize, since the fixed
62
+ // menu would otherwise drift away from its (scrolled) trigger.
63
+ document.addEventListener('mousedown', close)
64
+ document.addEventListener('keydown', onKey)
65
+ window.addEventListener('scroll', close, true)
66
+ window.addEventListener('resize', close)
67
+ return () => {
68
+ document.removeEventListener('mousedown', close)
69
+ document.removeEventListener('keydown', onKey)
70
+ window.removeEventListener('scroll', close, true)
71
+ window.removeEventListener('resize', close)
72
+ }
73
+ }, [open])
74
+
75
+ const toggle = (e: React.MouseEvent) => {
76
+ // Don't let an ancestor's onClick (e.g. a row navigation) fire too.
77
+ e.stopPropagation()
78
+ e.preventDefault()
79
+ if (!open && btnRef.current) {
80
+ const r = btnRef.current.getBoundingClientRect()
81
+ setPos({ top: r.bottom + 4, left: align === 'end' ? r.right : r.left, align })
82
+ }
83
+ setOpen((o) => !o)
84
+ }
85
+
86
+ return (
87
+ <>
88
+ <button
89
+ ref={btnRef}
90
+ type="button"
91
+ aria-haspopup="menu"
92
+ aria-expanded={open}
93
+ aria-controls={open ? menuId : undefined}
94
+ aria-label={label}
95
+ onClick={toggle}
96
+ className={typeof className === 'function' ? className({ open }) : className}
97
+ >
98
+ {trigger({ open })}
99
+ </button>
100
+ {open &&
101
+ pos &&
102
+ createPortal(
103
+ <div
104
+ id={menuId}
105
+ role="menu"
106
+ // Block the document mousedown-to-close for clicks inside the menu.
107
+ onMouseDown={(e) => e.stopPropagation()}
108
+ onClick={(e) => e.stopPropagation()}
109
+ style={{
110
+ position: 'fixed',
111
+ top: pos.top,
112
+ left: pos.left,
113
+ transform: pos.align === 'end' ? 'translateX(-100%)' : undefined,
114
+ }}
115
+ className={cn(
116
+ 'z-[200] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
117
+ menuClassName,
118
+ )}
119
+ >
120
+ <MenuCloseContext.Provider value={() => setOpen(false)}>
121
+ {children}
122
+ </MenuCloseContext.Provider>
123
+ </div>,
124
+ document.body,
125
+ )}
126
+ </>
127
+ )
128
+ }
129
+
130
+ export function DropdownMenuItem({
131
+ onSelect,
132
+ children,
133
+ className,
134
+ }: {
135
+ onSelect: () => void
136
+ children: React.ReactNode
137
+ className?: string
138
+ }) {
139
+ const close = useContext(MenuCloseContext)
140
+ return (
141
+ <button
142
+ type="button"
143
+ role="menuitem"
144
+ onClick={() => {
145
+ close()
146
+ onSelect()
147
+ }}
148
+ className={cn(
149
+ 'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-sidebar-accent',
150
+ className,
151
+ )}
152
+ >
153
+ {children}
154
+ </button>
155
+ )
156
+ }
@@ -0,0 +1,21 @@
1
+ import * as React from 'react'
2
+
3
+ import { cn } from '~/lib/utils'
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
12
+ 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
13
+ 'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Input }
@@ -0,0 +1,12 @@
1
+ import { RemixBrowser } from "@remix-run/react";
2
+ import { startTransition, StrictMode } from "react";
3
+ import { hydrateRoot } from "react-dom/client";
4
+
5
+ startTransition(() => {
6
+ hydrateRoot(
7
+ document,
8
+ <StrictMode>
9
+ <RemixBrowser />
10
+ </StrictMode>
11
+ );
12
+ });
@@ -0,0 +1,155 @@
1
+ import { PassThrough } from "node:stream";
2
+
3
+ import type { AppLoadContext, EntryContext } from "@remix-run/node";
4
+ import { createReadableStreamFromReadable } from "@remix-run/node";
5
+ import { RemixServer } from "@remix-run/react";
6
+ import * as isbotModule from "isbot";
7
+ import { renderToPipeableStream } from "react-dom/server";
8
+
9
+ const ABORT_DELAY = 5_000;
10
+
11
+ export default function handleRequest(
12
+ request: Request,
13
+ responseStatusCode: number,
14
+ responseHeaders: Headers,
15
+ remixContext: EntryContext,
16
+ loadContext: AppLoadContext
17
+ ) {
18
+ let prohibitOutOfOrderStreaming =
19
+ isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode;
20
+
21
+ return prohibitOutOfOrderStreaming
22
+ ? handleBotRequest(
23
+ request,
24
+ responseStatusCode,
25
+ responseHeaders,
26
+ remixContext
27
+ )
28
+ : handleBrowserRequest(
29
+ request,
30
+ responseStatusCode,
31
+ responseHeaders,
32
+ remixContext
33
+ );
34
+ }
35
+
36
+ // We have some Remix apps in the wild already running with isbot@3 so we need
37
+ // to maintain backwards compatibility even though we want new apps to use
38
+ // isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev.
39
+ function isBotRequest(userAgent: string | null) {
40
+ if (!userAgent) {
41
+ return false;
42
+ }
43
+
44
+ // isbot >= 3.8.0, >4
45
+ if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") {
46
+ return isbotModule.isbot(userAgent);
47
+ }
48
+
49
+ // isbot < 3.8.0
50
+ if ("default" in isbotModule && typeof isbotModule.default === "function") {
51
+ return isbotModule.default(userAgent);
52
+ }
53
+
54
+ return false;
55
+ }
56
+
57
+ function handleBotRequest(
58
+ request: Request,
59
+ responseStatusCode: number,
60
+ responseHeaders: Headers,
61
+ remixContext: EntryContext
62
+ ) {
63
+ return new Promise((resolve, reject) => {
64
+ let shellRendered = false;
65
+ const { pipe, abort } = renderToPipeableStream(
66
+ <RemixServer
67
+ context={remixContext}
68
+ url={request.url}
69
+ abortDelay={ABORT_DELAY}
70
+ />,
71
+ {
72
+ onAllReady() {
73
+ shellRendered = true;
74
+ const body = new PassThrough();
75
+ const stream = createReadableStreamFromReadable(body);
76
+
77
+ responseHeaders.set("Content-Type", "text/html");
78
+
79
+ resolve(
80
+ new Response(stream, {
81
+ headers: responseHeaders,
82
+ status: responseStatusCode,
83
+ })
84
+ );
85
+
86
+ pipe(body);
87
+ },
88
+ onShellError(error: unknown) {
89
+ reject(error);
90
+ },
91
+ onError(error: unknown) {
92
+ responseStatusCode = 500;
93
+ // Log streaming rendering errors from inside the shell. Don't log
94
+ // errors encountered during initial shell rendering since they'll
95
+ // reject and get logged in handleDocumentRequest.
96
+ if (shellRendered) {
97
+ console.error(error);
98
+ }
99
+ },
100
+ }
101
+ );
102
+
103
+ setTimeout(abort, ABORT_DELAY);
104
+ });
105
+ }
106
+
107
+ function handleBrowserRequest(
108
+ request: Request,
109
+ responseStatusCode: number,
110
+ responseHeaders: Headers,
111
+ remixContext: EntryContext
112
+ ) {
113
+ return new Promise((resolve, reject) => {
114
+ let shellRendered = false;
115
+ const { pipe, abort } = renderToPipeableStream(
116
+ <RemixServer
117
+ context={remixContext}
118
+ url={request.url}
119
+ abortDelay={ABORT_DELAY}
120
+ />,
121
+ {
122
+ onShellReady() {
123
+ shellRendered = true;
124
+ const body = new PassThrough();
125
+ const stream = createReadableStreamFromReadable(body);
126
+
127
+ responseHeaders.set("Content-Type", "text/html");
128
+
129
+ resolve(
130
+ new Response(stream, {
131
+ headers: responseHeaders,
132
+ status: responseStatusCode,
133
+ })
134
+ );
135
+
136
+ pipe(body);
137
+ },
138
+ onShellError(error: unknown) {
139
+ reject(error);
140
+ },
141
+ onError(error: unknown) {
142
+ responseStatusCode = 500;
143
+ // Log streaming rendering errors from inside the shell. Don't log
144
+ // errors encountered during initial shell rendering since they'll
145
+ // reject and get logged in handleDocumentRequest.
146
+ if (shellRendered) {
147
+ console.error(error);
148
+ }
149
+ },
150
+ }
151
+ );
152
+
153
+ setTimeout(abort, ABORT_DELAY);
154
+ });
155
+ }
@@ -0,0 +1,19 @@
1
+ // AUTO-GENERATED by scripts/generate-content.ts — DO NOT EDIT.
2
+ // Seed (empty site) committed so the engine typechecks before any generate. At
3
+ // RUNTIME the Vite `~/generated/*` alias redirects to the user's cwd, so this
4
+ // engine copy is only the type/seed fallback — the user's generated site.ts wins.
5
+ import type { GeneratedSite } from '~/lib/config/site'
6
+
7
+ export const SITE: GeneratedSite = {
8
+ site: {
9
+ title: 'Docs',
10
+ description: '',
11
+ lang: 'ru',
12
+ favicon: '/favicon.svg',
13
+ logo: { light: '/logo-light.svg', dark: '/logo-dark.svg' },
14
+ defaultTheme: 'dark',
15
+ },
16
+ projects: [],
17
+ general: { enabled: false, id: 'general', name: 'Без проекта', logo: '/projects/general.svg', description: '' },
18
+ ui: {},
19
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by scripts/generate-content.ts — DO NOT EDIT.
2
+ // Seed: every slot null (= engine default). `generate` rewrites this from the
3
+ // `components` overrides in docs.config.ts. Committed so a fresh checkout builds
4
+ // before the first generate. See app/lib/slots.tsx for the slot contract.
5
+ import type { ComponentType } from 'react'
6
+
7
+ export const Home: ComponentType<any> | null = null
8
+ export const DocPage: ComponentType<any> | null = null
9
+ export const TopBar: ComponentType<any> | null = null
10
+ export const Toc: ComponentType<any> | null = null
@@ -0,0 +1,60 @@
1
+ /* AUTO-GENERATED by scripts/generate-content.ts from docs.config.ts theme. DO NOT EDIT. */
2
+ :root {
3
+ --brand: oklch(0.42 0.158 286);
4
+ --background: oklch(1 0 0);
5
+ --foreground: oklch(0.145 0 0);
6
+ --card: oklch(1 0 0);
7
+ --card-foreground: oklch(0.145 0 0);
8
+ --popover: oklch(1 0 0);
9
+ --popover-foreground: oklch(0.145 0 0);
10
+ --primary: var(--brand);
11
+ --primary-foreground: oklch(0.985 0 0);
12
+ --secondary: oklch(0.97 0 0);
13
+ --secondary-foreground: oklch(0.205 0 0);
14
+ --muted: oklch(0.97 0 0);
15
+ --muted-foreground: oklch(0.556 0 0);
16
+ --accent: oklch(0.97 0 0);
17
+ --accent-foreground: oklch(0.205 0 0);
18
+ --destructive: oklch(0.577 0.245 27.325);
19
+ --border: oklch(0.922 0 0);
20
+ --input: oklch(0.922 0 0);
21
+ --ring: oklch(0.708 0 0);
22
+ --sidebar: oklch(0.985 0 0);
23
+ --sidebar-foreground: oklch(0.145 0 0);
24
+ --sidebar-primary: oklch(0.205 0 0);
25
+ --sidebar-primary-foreground: oklch(0.985 0 0);
26
+ --sidebar-accent: oklch(0.97 0 0);
27
+ --sidebar-accent-foreground: oklch(0.205 0 0);
28
+ --sidebar-border: oklch(0.922 0 0);
29
+ --sidebar-ring: oklch(0.708 0 0);
30
+ }
31
+
32
+ .dark {
33
+ --brand: oklch(0.68 0.14 286);
34
+ --background: oklch(0.145 0 0);
35
+ --foreground: oklch(0.985 0 0);
36
+ --card: oklch(0.205 0 0);
37
+ --card-foreground: oklch(0.985 0 0);
38
+ --popover: oklch(0.205 0 0);
39
+ --popover-foreground: oklch(0.985 0 0);
40
+ --primary: var(--brand);
41
+ --primary-foreground: oklch(0.205 0 0);
42
+ --secondary: oklch(0.269 0 0);
43
+ --secondary-foreground: oklch(0.985 0 0);
44
+ --muted: oklch(0.269 0 0);
45
+ --muted-foreground: oklch(0.708 0 0);
46
+ --accent: oklch(0.269 0 0);
47
+ --accent-foreground: oklch(0.985 0 0);
48
+ --destructive: oklch(0.704 0.191 22.216);
49
+ --border: oklch(1 0 0 / 10%);
50
+ --input: oklch(1 0 0 / 15%);
51
+ --ring: oklch(0.556 0 0);
52
+ --sidebar: oklch(0.205 0 0);
53
+ --sidebar-foreground: oklch(0.985 0 0);
54
+ --sidebar-primary: oklch(0.488 0.243 264.376);
55
+ --sidebar-primary-foreground: oklch(0.985 0 0);
56
+ --sidebar-accent: oklch(0.269 0 0);
57
+ --sidebar-accent-foreground: oklch(0.985 0 0);
58
+ --sidebar-border: oklch(1 0 0 / 10%);
59
+ --sidebar-ring: oklch(0.556 0 0);
60
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * App-side config reader (runtime).
3
+ *
4
+ * Reads the RESOLVED config that the build pipeline serialized to
5
+ * `app/generated/config.json` (under the user's cwd — same contract as the doc
6
+ * manifest in `content.server.ts`). It never imports the user's TS config, so it
7
+ * is safe to bundle into the Remix server.
8
+ *
9
+ * Read is synchronous + process-cached: `getConfig()` is called in server render
10
+ * paths (loaders, `projects.ts` helpers) where async would be awkward, and the
11
+ * config is tiny and immutable for the process lifetime.
12
+ */
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ import { docsConfigSchema, type DocsConfig } from './schema'
17
+ import { DEFAULT_THEME, defaultUiFor } from './defaults'
18
+
19
+ const CONFIG_PATH = path.join(process.cwd(), 'app', 'generated', 'config.json')
20
+
21
+ let cache: DocsConfig | null = null
22
+
23
+ /** A fully-defaulted empty config, used when no config.json has been generated. */
24
+ function emptyConfig(): DocsConfig {
25
+ const parsed = docsConfigSchema.parse({})
26
+ return {
27
+ ...parsed,
28
+ theme: { colors: { light: { ...DEFAULT_THEME.light }, dark: { ...DEFAULT_THEME.dark } } },
29
+ ui: { ...defaultUiFor(parsed.site.lang) },
30
+ }
31
+ }
32
+
33
+ /** The resolved docs config for this site (process-cached). */
34
+ export function getConfig(): DocsConfig {
35
+ if (cache) return cache
36
+ try {
37
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8')
38
+ // Already resolved + validated at generate time; re-parse defensively so a
39
+ // hand-edited file still can't violate the schema at runtime.
40
+ cache = docsConfigSchema.parse(JSON.parse(raw))
41
+ } catch {
42
+ cache = emptyConfig()
43
+ }
44
+ return cache
45
+ }
46
+
47
+ /** Resolved UI strings for the current site (lang-defaulted, user-overridden). */
48
+ export function getUi(): Record<string, string> {
49
+ return getConfig().ui
50
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shipped defaults: theme tokens + per-locale UI strings.
3
+ *
4
+ * These reproduce this project's ORIGINAL hardcoded values, so a config that
5
+ * omits `theme`/`ui` (or omits individual keys) renders byte-for-byte as before.
6
+ * `loadConfig` merges authored values over these.
7
+ *
8
+ * UI strings are keyed by `site.lang`. Only the chrome strings users typically
9
+ * want to rebrand are externalized here; the keyboard-shortcut cheatsheet labels
10
+ * (`useKeyboardShortcuts.ts`) remain in code as an advanced concern.
11
+ */
12
+
13
+ /** Default OKLCH theme tokens — mirror of `app/styles/tailwind.css` :root/.dark. */
14
+ export const DEFAULT_THEME = {
15
+ light: {
16
+ '--brand': 'oklch(0.42 0.158 286)',
17
+ '--background': 'oklch(1 0 0)',
18
+ '--foreground': 'oklch(0.145 0 0)',
19
+ '--card': 'oklch(1 0 0)',
20
+ '--card-foreground': 'oklch(0.145 0 0)',
21
+ '--popover': 'oklch(1 0 0)',
22
+ '--popover-foreground': 'oklch(0.145 0 0)',
23
+ '--primary': 'var(--brand)',
24
+ '--primary-foreground': 'oklch(0.985 0 0)',
25
+ '--secondary': 'oklch(0.97 0 0)',
26
+ '--secondary-foreground': 'oklch(0.205 0 0)',
27
+ '--muted': 'oklch(0.97 0 0)',
28
+ '--muted-foreground': 'oklch(0.556 0 0)',
29
+ '--accent': 'oklch(0.97 0 0)',
30
+ '--accent-foreground': 'oklch(0.205 0 0)',
31
+ '--destructive': 'oklch(0.577 0.245 27.325)',
32
+ '--border': 'oklch(0.922 0 0)',
33
+ '--input': 'oklch(0.922 0 0)',
34
+ '--ring': 'oklch(0.708 0 0)',
35
+ '--sidebar': 'oklch(0.985 0 0)',
36
+ '--sidebar-foreground': 'oklch(0.145 0 0)',
37
+ '--sidebar-primary': 'oklch(0.205 0 0)',
38
+ '--sidebar-primary-foreground': 'oklch(0.985 0 0)',
39
+ '--sidebar-accent': 'oklch(0.97 0 0)',
40
+ '--sidebar-accent-foreground': 'oklch(0.205 0 0)',
41
+ '--sidebar-border': 'oklch(0.922 0 0)',
42
+ '--sidebar-ring': 'oklch(0.708 0 0)',
43
+ } as Record<string, string>,
44
+ dark: {
45
+ '--brand': 'oklch(0.68 0.14 286)',
46
+ '--background': 'oklch(0.145 0 0)',
47
+ '--foreground': 'oklch(0.985 0 0)',
48
+ '--card': 'oklch(0.205 0 0)',
49
+ '--card-foreground': 'oklch(0.985 0 0)',
50
+ '--popover': 'oklch(0.205 0 0)',
51
+ '--popover-foreground': 'oklch(0.985 0 0)',
52
+ '--primary': 'var(--brand)',
53
+ '--primary-foreground': 'oklch(0.205 0 0)',
54
+ '--secondary': 'oklch(0.269 0 0)',
55
+ '--secondary-foreground': 'oklch(0.985 0 0)',
56
+ '--muted': 'oklch(0.269 0 0)',
57
+ '--muted-foreground': 'oklch(0.708 0 0)',
58
+ '--accent': 'oklch(0.269 0 0)',
59
+ '--accent-foreground': 'oklch(0.985 0 0)',
60
+ '--destructive': 'oklch(0.704 0.191 22.216)',
61
+ '--border': 'oklch(1 0 0 / 10%)',
62
+ '--input': 'oklch(1 0 0 / 15%)',
63
+ '--ring': 'oklch(0.556 0 0)',
64
+ '--sidebar': 'oklch(0.205 0 0)',
65
+ '--sidebar-foreground': 'oklch(0.985 0 0)',
66
+ '--sidebar-primary': 'oklch(0.488 0.243 264.376)',
67
+ '--sidebar-primary-foreground': 'oklch(0.985 0 0)',
68
+ '--sidebar-accent': 'oklch(0.269 0 0)',
69
+ '--sidebar-accent-foreground': 'oklch(0.985 0 0)',
70
+ '--sidebar-border': 'oklch(1 0 0 / 10%)',
71
+ '--sidebar-ring': 'oklch(0.556 0 0)',
72
+ } as Record<string, string>,
73
+ }
74
+
75
+ /**
76
+ * The UI string catalogue. Keys are stable identifiers used in components via
77
+ * `getUi(config)`; values are the originals (ru). `en` is a best-effort fallback
78
+ * for non-ru sites so an unset `ui` doesn't render Russian on an English site.
79
+ */
80
+ export const UI_STRINGS: Record<string, Record<string, string>> = {
81
+ ru: {
82
+ projects: 'Проекты',
83
+ selectProject: 'Выберите проект',
84
+ noProject: 'Без проекта',
85
+ onThisPage: 'На этой странице',
86
+ properties: 'Свойства',
87
+ shortcuts: 'Горячие клавиши',
88
+ searchProjectFilter: 'Проект',
89
+ close: 'Закрыть',
90
+ closePanel: 'Закрыть панель',
91
+ closeSearch: 'Закрыть поиск',
92
+ closeTab: 'Закрыть вкладку',
93
+ home: 'Главная',
94
+ files: 'Файлы',
95
+ search: 'Поиск',
96
+ toggleTheme: 'Переключить тему',
97
+ },
98
+ en: {
99
+ projects: 'Projects',
100
+ selectProject: 'Select a project',
101
+ noProject: 'No project',
102
+ onThisPage: 'On this page',
103
+ properties: 'Properties',
104
+ shortcuts: 'Keyboard shortcuts',
105
+ searchProjectFilter: 'Project',
106
+ close: 'Close',
107
+ closePanel: 'Close panel',
108
+ closeSearch: 'Close search',
109
+ closeTab: 'Close tab',
110
+ home: 'Home',
111
+ files: 'Files',
112
+ search: 'Search',
113
+ toggleTheme: 'Toggle theme',
114
+ },
115
+ }
116
+
117
+ /** UI strings for a language, falling back to `en` then `ru`. */
118
+ export function defaultUiFor(lang: string): Record<string, string> {
119
+ return UI_STRINGS[lang] ?? UI_STRINGS.en ?? UI_STRINGS.ru
120
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Build-side config loader (Node only — imports the user's TS config).
3
+ *
4
+ * Runs from the build pipeline (`scripts/*`), which executes under
5
+ * `node --experimental-strip-types`, so it can `import()` the user's
6
+ * `docs.config.ts` directly. It validates against `docsConfigSchema`, merges the
7
+ * shipped theme/ui defaults, fills per-project derived defaults (logo, landing),
8
+ * and returns a fully-resolved `DocsConfig`.
9
+ *
10
+ * The running APP must NOT call this (Vite can't bundle the user's cwd TS file);
11
+ * the generator serializes the resolved config to `app/generated/config.json`,
12
+ * and the app reads that JSON at runtime via `config.server.ts`. This module is
13
+ * the single place the TS config is ever imported.
14
+ */
15
+ import { pathToFileURL } from 'node:url'
16
+ import path from 'node:path'
17
+ import fs from 'node:fs'
18
+
19
+ import { docsConfigSchema, type DocsConfig } from './schema.ts'
20
+ import { DEFAULT_THEME, defaultUiFor } from './defaults.ts'
21
+
22
+ /** Candidate config filenames, in resolution order, relative to cwd. */
23
+ const CONFIG_NAMES = ['docs.config.ts', 'docs.config.js', 'docs.config.mjs']
24
+
25
+ /** Locate the user's config file in `cwd`, or null when none exists. */
26
+ function findConfigFile(cwd: string): string | null {
27
+ for (const name of CONFIG_NAMES) {
28
+ const p = path.join(cwd, name)
29
+ if (fs.existsSync(p)) return p
30
+ }
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Load + validate + fully resolve the docs config from `cwd` (defaults to
36
+ * `process.cwd()`). With no config file, returns an all-defaults config so the
37
+ * engine still runs (empty site, no projects) rather than throwing.
38
+ */
39
+ export async function loadConfig(cwd: string = process.cwd()): Promise<DocsConfig> {
40
+ const file = findConfigFile(cwd)
41
+ let authored: unknown = {}
42
+ if (file) {
43
+ // Cache-bust with the file mtime so repeated loads in one watch session
44
+ // pick up edits (import() caches by URL otherwise).
45
+ const mtime = fs.statSync(file).mtimeMs
46
+ const mod = await import(`${pathToFileURL(file).href}?t=${mtime}`)
47
+ authored = mod.default ?? mod.config ?? mod
48
+ }
49
+
50
+ const parsed = docsConfigSchema.safeParse(authored)
51
+ if (!parsed.success) {
52
+ throw new Error(
53
+ `Invalid docs.config:\n\n${JSON.stringify(parsed.error.format(), null, 2)}`,
54
+ )
55
+ }
56
+ return resolveDerived(parsed.data)
57
+ }
58
+
59
+ /**
60
+ * Fill values that depend on other fields and merge shipped defaults:
61
+ * - per-project `logo` → `/projects/<id>.svg`, `landing` → first-doc URL (left
62
+ * null here; the app derives it from the manifest when unset),
63
+ * - theme colors merged OVER `DEFAULT_THEME`,
64
+ * - `ui` merged OVER the per-`lang` default strings.
65
+ */
66
+ function resolveDerived(config: DocsConfig): DocsConfig {
67
+ const projects = config.projects.map((p) => ({
68
+ ...p,
69
+ logo: p.logo ?? `/projects/${p.id}.svg`,
70
+ }))
71
+
72
+ const theme = {
73
+ colors: {
74
+ light: { ...DEFAULT_THEME.light, ...config.theme.colors.light },
75
+ dark: { ...DEFAULT_THEME.dark, ...config.theme.colors.dark },
76
+ },
77
+ }
78
+
79
+ const ui = { ...defaultUiFor(config.site.lang), ...config.ui }
80
+
81
+ return { ...config, projects, theme, ui }
82
+ }