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,131 @@
1
+ /**
2
+ * `DocsConfig` — the single source of truth a user authors in `docs.config.ts`.
3
+ *
4
+ * Both the build pipeline (`scripts/*`) and the running app (`app/lib/*.server`,
5
+ * route loaders, components via `getConfig()`) read the SAME resolved config, so
6
+ * a value defined once flows everywhere. Authored values are partial; every field
7
+ * has a default (see `defaults.ts`) so omitting a key reproduces the original
8
+ * hardcoded behavior of this project.
9
+ *
10
+ * Validated with zod (already a dependency). The exported `defineConfig` is just
11
+ * an identity helper that gives users autocomplete + type-checking on the literal
12
+ * they write; actual validation/defaulting happens in `loadConfig` (`load.ts`).
13
+ */
14
+ import { z } from 'zod'
15
+
16
+ /** A theme color map: token name → CSS color value (typically OKLCH). */
17
+ const colorMap = z.record(z.string(), z.string())
18
+
19
+ /**
20
+ * A content source = one project = one Obsidian-style vault directory. Maps onto
21
+ * the first path segment of every doc id it produces (`output`/`id`), exactly as
22
+ * before. `source` may be a git submodule path, a loose content folder, or any
23
+ * relative/absolute path — the generator just globs it.
24
+ */
25
+ export const projectSchema = z.object({
26
+ /** First id segment + output dir name, e.g. `krista`. */
27
+ id: z.string().min(1),
28
+ /** Display name in the switcher / home cards. */
29
+ name: z.string().min(1),
30
+ /** Directory of markdown/canvas files. Submodule, loose folder, or any path. */
31
+ source: z.string().min(1),
32
+ /** Logo under the user's `/public`. Defaults to `/projects/<id>.svg`. */
33
+ logo: z.string().optional(),
34
+ /** Landing URL when switching to this project. Defaults to its first doc. */
35
+ landing: z.string().optional(),
36
+ /** Short blurb, reused on home cards. */
37
+ description: z.string().default(''),
38
+ /** Ingest `.canvas` files from this source too. */
39
+ canvas: z.boolean().default(false),
40
+ /** Globs (relative to `source`) to skip, e.g. `['CLAUDE.md']`. */
41
+ ignore: z.array(z.string()).default([]),
42
+ })
43
+
44
+ /** The "no project" bucket: docs not under any named project, served at root. */
45
+ export const generalSchema = z.object({
46
+ enabled: z.boolean().default(false),
47
+ name: z.string().default('Без проекта'),
48
+ /** Directory of loose docs. When unset, the bucket has no docs. */
49
+ source: z.string().optional(),
50
+ logo: z.string().default('/projects/general.svg'),
51
+ description: z.string().default(''),
52
+ ignore: z.array(z.string()).default([]),
53
+ })
54
+
55
+ export const siteSchema = z.object({
56
+ title: z.string().default('Docs'),
57
+ /** Home-page blurb under the title. */
58
+ description: z.string().default(''),
59
+ /** BCP-47-ish tag; drives `localeCompare` sorting + Pagefind `forceLanguage`. */
60
+ lang: z.string().default('ru'),
61
+ favicon: z.string().default('/favicon.svg'),
62
+ logo: z
63
+ .object({
64
+ light: z.string().default('/iti-logo-light.svg'),
65
+ dark: z.string().default('/iti-logo-dark.svg'),
66
+ })
67
+ .prefault({}),
68
+ defaultTheme: z.enum(['dark', 'light']).default('dark'),
69
+ })
70
+
71
+ export const themeSchema = z.object({
72
+ /** OKLCH (or any CSS color) token overrides, merged OVER the shipped defaults. */
73
+ colors: z
74
+ .object({
75
+ light: colorMap.default({}),
76
+ dark: colorMap.default({}),
77
+ })
78
+ .prefault({}),
79
+ })
80
+
81
+ /**
82
+ * Component override slots: slot name → path (relative to the user's project) of a
83
+ * `.tsx` exporting a default component that replaces the engine's. Unset slots use
84
+ * the engine default. Resolved by `app/lib/slots.ts`.
85
+ */
86
+ export const componentsSchema = z
87
+ .object({
88
+ Home: z.string().optional(),
89
+ DocPage: z.string().optional(),
90
+ Sidebar: z.string().optional(),
91
+ TopBar: z.string().optional(),
92
+ Toc: z.string().optional(),
93
+ Search: z.string().optional(),
94
+ Layout: z.string().optional(),
95
+ })
96
+ .default({})
97
+
98
+ /**
99
+ * UI string overrides. Keys mirror the catalogued in-app literals; defaults ship
100
+ * per `site.lang` (see `defaults.ts`). Partial — unset keys fall back to defaults.
101
+ */
102
+ export const uiSchema = z.record(z.string(), z.string()).default({})
103
+
104
+ export const docsConfigSchema = z.object({
105
+ site: siteSchema.prefault({}),
106
+ projects: z.array(projectSchema).default([]),
107
+ general: generalSchema.prefault({}),
108
+ theme: themeSchema.prefault({}),
109
+ components: componentsSchema,
110
+ ui: uiSchema,
111
+ /**
112
+ * Reserved for custom remark/rehype plugins + callout types. NOT wired in this
113
+ * phase (the markdown pipeline stays fixed); accepted so configs are
114
+ * forward-compatible.
115
+ */
116
+ markdown: z.unknown().optional(),
117
+ })
118
+
119
+ /** Authored (input) shape — what a user writes; most fields optional. */
120
+ export type DocsUserConfig = z.input<typeof docsConfigSchema>
121
+ /** Resolved (output) shape — every field present, used everywhere internally. */
122
+ export type DocsConfig = z.output<typeof docsConfigSchema>
123
+ export type ProjectConfig = z.output<typeof projectSchema>
124
+
125
+ /**
126
+ * Identity helper for `docs.config.ts` authors: gives editor autocomplete and
127
+ * type errors on the literal. Validation + defaulting happen in `loadConfig`.
128
+ */
129
+ export function defineConfig(config: DocsUserConfig): DocsUserConfig {
130
+ return config
131
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The client-safe site shape.
3
+ *
4
+ * The build pipeline emits `app/generated/site.ts` (a plain-literal module)
5
+ * conforming to `GeneratedSite`. Unlike `config.json` (read via `fs` in
6
+ * `.server` code), this module is IMPORTED, so Vite bundles it into BOTH the
7
+ * client and server bundles — letting client components (`ProjectSwitcher`,
8
+ * `_index`, `Search`, `MobileProjectsPanel`) read projects/branding/ui strings
9
+ * synchronously with no runtime file access.
10
+ *
11
+ * It intentionally carries only the serializable, non-sensitive subset of the
12
+ * config (no theme CSS, no markdown plugins). The seed file shipped in the repo
13
+ * is overwritten on every `generate`.
14
+ */
15
+
16
+ export interface SiteProject {
17
+ id: string
18
+ name: string
19
+ logo: string
20
+ /** Landing URL; resolved to the project's first doc at generate time when unset. */
21
+ landing: string
22
+ description: string
23
+ }
24
+
25
+ export interface GeneratedSite {
26
+ site: {
27
+ title: string
28
+ description: string
29
+ lang: string
30
+ favicon: string
31
+ logo: { light: string; dark: string }
32
+ defaultTheme: 'dark' | 'light'
33
+ }
34
+ projects: SiteProject[]
35
+ general: {
36
+ enabled: boolean
37
+ id: string
38
+ name: string
39
+ logo: string
40
+ description: string
41
+ }
42
+ ui: Record<string, string>
43
+ }
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ export interface DocIndexEntry {
5
+ id: string
6
+ title: string | null
7
+ draft: boolean
8
+ tableOfContents: boolean
9
+ tags: string[]
10
+ isCanvas: boolean
11
+ }
12
+
13
+ export interface Heading {
14
+ depth: number
15
+ slug: string
16
+ text: string
17
+ }
18
+
19
+ export interface Doc {
20
+ id: string
21
+ frontmatter: Record<string, unknown>
22
+ headings: Heading[]
23
+ html: string
24
+ }
25
+
26
+ // The generated manifest lives at <project>/app/generated and is read at runtime
27
+ // from the working directory (it is not bundled into the server build, so we
28
+ // resolve it from cwd rather than relative to this compiled module).
29
+ const GENERATED_DIR = path.join(process.cwd(), 'app', 'generated')
30
+
31
+ let indexCache: DocIndexEntry[] | null = null
32
+ let permalinkCache: { toId: Record<string, string>; toPermalink: Record<string, string> } | null = null
33
+
34
+ /**
35
+ * Load the permalink map (built at generate time from each doc's `permalink`
36
+ * frontmatter). Keys and values are normalized id-style paths with no leading
37
+ * or trailing slashes, matching how the route loader strips request paths.
38
+ * Returns both directions: permalink→id (to serve) and id→permalink (so a
39
+ * file-path request can redirect to its canonical permalink).
40
+ */
41
+ async function getPermalinks() {
42
+ if (!permalinkCache) {
43
+ let toId: Record<string, string> = {}
44
+ try {
45
+ const raw = await fs.readFile(path.join(GENERATED_DIR, 'permalinks.json'), 'utf8')
46
+ toId = JSON.parse(raw) as Record<string, string>
47
+ } catch {
48
+ toId = {} // no permalinks defined → empty map
49
+ }
50
+ const toPermalink: Record<string, string> = {}
51
+ for (const [permalink, id] of Object.entries(toId)) {
52
+ // If a doc has several permalinks, the first wins as its canonical URL.
53
+ if (!toPermalink[id]) toPermalink[id] = permalink
54
+ }
55
+ permalinkCache = { toId, toPermalink }
56
+ }
57
+ return permalinkCache
58
+ }
59
+
60
+ /** The doc id a permalink points at, or null if the path is not a permalink. */
61
+ export async function resolvePermalink(pathSlug: string): Promise<string | null> {
62
+ const { toId } = await getPermalinks()
63
+ return toId[pathSlug] ?? null
64
+ }
65
+
66
+ /** The canonical permalink for a doc id, or null if the doc has none. */
67
+ export async function getPermalinkForId(id: string): Promise<string | null> {
68
+ const { toPermalink } = await getPermalinks()
69
+ return toPermalink[id] ?? null
70
+ }
71
+
72
+ /**
73
+ * The canonical URL for a doc id: its permalink URL when it has one, else its
74
+ * file-path URL `/{id}/`. Used by link emitters (e.g. the sidebar) so internal
75
+ * links point straight at the permalink instead of taking a 301 redirect.
76
+ */
77
+ export async function getCanonicalUrl(id: string): Promise<string> {
78
+ const permalink = await getPermalinkForId(id)
79
+ return permalink ? `/${permalink}/` : `/${id}/`
80
+ }
81
+
82
+ /** All non-draft docs from the generated index (cached for the process). */
83
+ export async function getAllDocs(): Promise<DocIndexEntry[]> {
84
+ if (!indexCache) {
85
+ const raw = await fs.readFile(path.join(GENERATED_DIR, 'index.json'), 'utf8')
86
+ indexCache = JSON.parse(raw) as DocIndexEntry[]
87
+ }
88
+ return indexCache.filter((d) => !d.draft)
89
+ }
90
+
91
+ /** Load a single compiled doc by its route id (e.g. "krista/глоссарий/коллекция"). */
92
+ export async function getDoc(id: string): Promise<Doc | null> {
93
+ // Guard against path traversal: ids are slugified, so no "." segments.
94
+ const safe = id
95
+ .split('/')
96
+ .filter((s) => s && s !== '.' && s !== '..')
97
+ .join('/')
98
+ if (!safe) return null
99
+ try {
100
+ const raw = await fs.readFile(path.join(GENERATED_DIR, 'docs', `${safe}.json`), 'utf8')
101
+ return JSON.parse(raw) as Doc
102
+ } catch {
103
+ return null
104
+ }
105
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Project registry — the runtime/UI layer over the generated site data.
3
+ *
4
+ * A "project" maps onto the **first path segment** of every doc `id`
5
+ * (e.g. `krista-partners/о-проекте/...` → project `krista-partners`), which in
6
+ * turn is one content source (one git submodule / loose folder, see
7
+ * `docs.config.ts` → `scripts/generate-content.ts`). Nothing here touches the
8
+ * build pipeline; it reads the already-generated `app/generated/site.ts`.
9
+ *
10
+ * That module is plain literals bundled by Vite, so this file is ISOMORPHIC —
11
+ * safe to import from client components (`ProjectSwitcher`, `_index`, `Search`,
12
+ * `MobileProjectsPanel`) and from server code (`root.tsx`, `sidebar.server`)
13
+ * alike, with no runtime file access. The project list, branding, and labels all
14
+ * come from the user's `docs.config.ts` via the generator.
15
+ *
16
+ * The active project is derived from the URL (see `getActiveProjectId`), so it's
17
+ * SSR-friendly, shareable, and survives reload with no extra client state.
18
+ */
19
+ import { SITE } from '~/generated/site'
20
+
21
+ export interface Project {
22
+ /** Matches the first segment of a doc id, e.g. `krista-partners`. */
23
+ id: string
24
+ /** Display name shown in the switcher. */
25
+ name: string
26
+ /** Logo path under /public. */
27
+ logo: string
28
+ /** Where "switch to this project" navigates (its landing doc). */
29
+ landing: string
30
+ /** Short blurb, reused on the home page cards. */
31
+ description: string
32
+ }
33
+
34
+ /** Id of the built-in pseudo-project for docs that aren't under a known vault. */
35
+ export const GENERAL_PROJECT_ID = 'general'
36
+
37
+ /** The named projects, from the generated site data (authored in docs.config.ts). */
38
+ export const PROJECTS: Project[] = SITE.projects.map((p) => ({
39
+ id: p.id,
40
+ name: p.name,
41
+ logo: p.logo,
42
+ landing: p.landing,
43
+ description: p.description,
44
+ }))
45
+
46
+ /** The pseudo-project that owns any doc not under a known project. */
47
+ export const GENERAL_PROJECT: Project = {
48
+ id: GENERAL_PROJECT_ID,
49
+ name: SITE.general.name,
50
+ logo: SITE.general.logo,
51
+ landing: '/',
52
+ description: SITE.general.description,
53
+ }
54
+
55
+ /**
56
+ * All projects shown in the switcher. The `general` bucket is appended only when
57
+ * it is enabled AND actually has docs (the generator sets `general.enabled`
58
+ * accordingly), matching the prior behavior.
59
+ */
60
+ export function getProjects(): Project[] {
61
+ return SITE.general.enabled ? [...PROJECTS, GENERAL_PROJECT] : PROJECTS
62
+ }
63
+
64
+ const byId = new Map<string, Project>([...PROJECTS, GENERAL_PROJECT].map((p) => [p.id, p]))
65
+
66
+ /** The project a doc belongs to, from its first id segment. Unknown → `general`. */
67
+ export function getProjectIdForDoc(docId: string): string {
68
+ const first = docId.split('/')[0] ?? ''
69
+ return byId.has(first) && first !== GENERAL_PROJECT_ID ? first : GENERAL_PROJECT_ID
70
+ }
71
+
72
+ /** Look up a project by id (incl. `general`). */
73
+ export function getProject(id: string): Project | undefined {
74
+ return byId.get(id)
75
+ }
76
+
77
+ /**
78
+ * Active project derived from a request pathname. The first non-empty segment is
79
+ * the project id, or `null` when the path names no known project (e.g. on `/`),
80
+ * so the home page can render with no project selected and no sidebar.
81
+ */
82
+ export function getActiveProjectId(pathname: string): string | null {
83
+ const first = decodeURIComponent(pathname).split('/').filter(Boolean)[0] ?? ''
84
+ if (byId.has(first) && first !== GENERAL_PROJECT_ID) return first
85
+ return null
86
+ }
@@ -0,0 +1,113 @@
1
+ import { getAllDocs, getCanonicalUrl, type DocIndexEntry } from './content.server'
2
+ import { getProjectIdForDoc } from './projects'
3
+
4
+ export type SidebarNodeType = 'directory' | 'file' | 'canvas' | 'image'
5
+
6
+ export interface SidebarNode {
7
+ label: string
8
+ href?: string
9
+ nodeType: SidebarNodeType
10
+ children: SidebarNode[]
11
+ }
12
+
13
+ function prettify(slug: string): string {
14
+ const cleaned = decodeURIComponent(slug).replace(/-/g, ' ')
15
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
16
+ }
17
+
18
+ function detectNodeType(entry: DocIndexEntry): SidebarNodeType {
19
+ if (entry.isCanvas) return 'canvas'
20
+ return 'file'
21
+ }
22
+
23
+ interface BuildNode {
24
+ label: string
25
+ href?: string
26
+ nodeType?: SidebarNodeType
27
+ childMap: Map<string, BuildNode>
28
+ }
29
+
30
+ /**
31
+ * Build the sidebar tree for a **single project**. Only docs belonging to
32
+ * `projectId` (by their first id segment, see `getProjectIdForDoc`) are included,
33
+ * and that leading project segment is dropped from the tree — the project's own
34
+ * folders sit at the top level, since the project switcher already names the
35
+ * project. Hrefs keep the full id so navigation still resolves.
36
+ */
37
+ export async function buildSidebar(projectId: string): Promise<SidebarNode[]> {
38
+ const docs = await getAllDocs()
39
+ const rootMap = new Map<string, BuildNode>()
40
+
41
+ for (const entry of docs) {
42
+ if (getProjectIdForDoc(entry.id) !== projectId) continue
43
+
44
+ // Strip the leading project segment; the remaining segments form the tree.
45
+ const segments = entry.id.split('/').slice(1)
46
+ if (segments.length === 0) continue
47
+ let current = rootMap
48
+
49
+ for (let i = 0; i < segments.length; i++) {
50
+ const seg = segments[i]!
51
+ const isLast = i === segments.length - 1
52
+
53
+ if (!current.has(seg)) {
54
+ current.set(seg, {
55
+ label: isLast ? (entry.title ?? prettify(seg)) : prettify(seg),
56
+ childMap: new Map(),
57
+ })
58
+ }
59
+
60
+ const node = current.get(seg)!
61
+
62
+ if (isLast) {
63
+ // Canonical URL → permalink when the doc has one, else /{id}/.
64
+ node.href = await getCanonicalUrl(entry.id)
65
+ node.label = entry.title ?? prettify(seg)
66
+ node.nodeType = detectNodeType(entry)
67
+ }
68
+
69
+ current = node.childMap
70
+ }
71
+ }
72
+
73
+ return mapToNodes(rootMap)
74
+ }
75
+
76
+ function mapToNodes(map: Map<string, BuildNode>): SidebarNode[] {
77
+ return Array.from(map.entries()).map(([, val]) => {
78
+ const children = mapToNodes(val.childMap).sort((a, b) => a.label.localeCompare(b.label, 'ru'))
79
+ return {
80
+ label: val.label,
81
+ href: val.href,
82
+ nodeType: val.nodeType ?? (children.length > 0 ? 'directory' : 'file'),
83
+ children,
84
+ }
85
+ })
86
+ }
87
+
88
+ export interface FlatSidebarItem {
89
+ name: string
90
+ href?: string
91
+ type: SidebarNodeType
92
+ children: string[]
93
+ }
94
+
95
+ export type FlatSidebarMap = Record<string, FlatSidebarItem>
96
+
97
+ /** Flatten the tree into the id-keyed map shape the headless-tree sidebar consumes. */
98
+ export function flattenSidebar(nodes: SidebarNode[]): FlatSidebarMap {
99
+ const map: FlatSidebarMap = {}
100
+ let counter = 0
101
+
102
+ function walk(node: SidebarNode): string {
103
+ const id = `n${counter++}`
104
+ const childIds = node.children.map((child) => walk(child))
105
+ map[id] = { name: node.label, href: node.href, type: node.nodeType, children: childIds }
106
+ return id
107
+ }
108
+
109
+ const rootChildIds = nodes.map((node) => walk(node))
110
+ map['root'] = { name: 'Root', type: 'directory', children: rootChildIds }
111
+
112
+ return map
113
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Isomorphic accessors for site branding + UI strings.
3
+ *
4
+ * Thin ergonomic layer over the generated `~/generated/site` module (plain
5
+ * literals bundled by Vite, safe on client + server). Components import `site`
6
+ * for branding (title, logos, favicon) and `t(key)` for localized UI strings
7
+ * instead of reaching into the generated shape directly — so the generated
8
+ * contract can evolve without touching call sites.
9
+ */
10
+ import { SITE } from '~/generated/site'
11
+
12
+ /** Site branding/meta (title, description, lang, favicon, logos, default theme). */
13
+ export const site = SITE.site
14
+
15
+ /**
16
+ * A localized UI string by key (see `app/lib/config/defaults.ts` for the
17
+ * catalogue). Falls back to the key itself if missing, so a typo is visible
18
+ * rather than rendering blank.
19
+ */
20
+ export function t(key: string): string {
21
+ return SITE.ui[key] ?? key
22
+ }
23
+
24
+ /** The page `<title>` for a doc: `"<docTitle> — <siteTitle>"`, or just the site title. */
25
+ export function pageTitle(docTitle?: string | null): string {
26
+ return docTitle ? `${docTitle} — ${site.title}` : site.title
27
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Component override slots.
3
+ *
4
+ * The generated `~/generated/slots` module exports each slot as either the user's
5
+ * override component (from `docs.config.ts` `components`) or `null`. This module
6
+ * pairs those with the engine defaults and exposes the resolved component each
7
+ * call site should render — so route/layout code imports from here and never
8
+ * needs to know whether an override exists.
9
+ *
10
+ * - `TopBar` / `Toc` are sub-components: resolved to override-or-default here, so
11
+ * `root.tsx` / `$.tsx` import the resolved component directly.
12
+ * - `Home` / `DocPage` are route bodies: exposed as the override-or-null
13
+ * `HomeOverride` / `DocPageOverride`, since the route file owns its own default
14
+ * body (importing the engine route component here would be circular).
15
+ *
16
+ * To add a slot, expose the component on the generated module + map it here.
17
+ */
18
+ import * as overrides from '~/generated/slots'
19
+
20
+ import DefaultTopBar from '~/components/TopBar'
21
+ import DefaultToc from '~/components/Toc'
22
+
23
+ /** Top bar (logo + switcher + search + toggle). Override or engine default. */
24
+ export const TopBar = overrides.TopBar ?? DefaultTopBar
25
+
26
+ /** Right-column table of contents. Override or engine default. */
27
+ export const Toc = overrides.Toc ?? DefaultToc
28
+
29
+ /** Home page body override, or null to use the engine's default home route body. */
30
+ export const HomeOverride = overrides.Home
31
+
32
+ /** Doc page body override, or null to use the engine's default doc route body. */
33
+ export const DocPageOverride = overrides.DocPage
@@ -0,0 +1,128 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from 'react'
9
+
10
+ /**
11
+ * Editor-style tabs (VS Code feel) layered over Remix navigation. The URL stays
12
+ * the source of truth for which doc is shown (route `$.tsx` renders it); this
13
+ * context only tracks the *list* of open tabs and which actions add/remove them.
14
+ *
15
+ * Tabs are client-only state, persisted to localStorage **scoped per project**
16
+ * (key `tabs:<projectId>`). The provider is mounted in `root.tsx` above the
17
+ * `<Outlet/>` so both the sidebar (which opens tabs) and the `TabBar` (which
18
+ * renders them) can read/update the same list.
19
+ */
20
+
21
+ export interface Tab {
22
+ /** Doc href, e.g. "/krista/глоссарий/коллекция/" — also the navigation target. */
23
+ path: string
24
+ /** Display label (the sidebar item name). */
25
+ title: string
26
+ }
27
+
28
+ interface TabsContextValue {
29
+ tabs: Tab[]
30
+ /** Add a tab (de-duped by normalised path). No-op if already open. */
31
+ openTab: (path: string, title: string) => void
32
+ /** Remove a tab by path. Returns the neighbor to activate, or null if none/irrelevant. */
33
+ closeTab: (path: string) => void
34
+ /** Close every open tab. */
35
+ closeAll: () => void
36
+ /** Whether any tabs are open — drives the sidebar single-click rule + bar visibility. */
37
+ hasTabs: boolean
38
+ }
39
+
40
+ const TabsContext = createContext<TabsContextValue | null>(null)
41
+
42
+ /** Normalise a path for comparison/de-dupe: decoded, no trailing slash. Mirrors Sidebar.normPath. */
43
+ export function normTabPath(p?: string): string {
44
+ if (!p) return ''
45
+ try {
46
+ return decodeURIComponent(p).replace(/\/+$/, '')
47
+ } catch {
48
+ return p.replace(/\/+$/, '')
49
+ }
50
+ }
51
+
52
+ const storageKey = (projectId: string) => `tabs:${projectId}`
53
+
54
+ export function TabsProvider({
55
+ projectId,
56
+ children,
57
+ }: {
58
+ /** Active project, or null on pages with no project (e.g. `/`). */
59
+ projectId: string | null
60
+ children: React.ReactNode
61
+ }) {
62
+ // Start empty so SSR and the first client render match; load from
63
+ // localStorage after mount (same hydration-safe pattern as the sidebar tree).
64
+ const [tabs, setTabs] = useState<Tab[]>([])
65
+ const [loaded, setLoaded] = useState(false)
66
+
67
+ // Load this project's tabs whenever the active project changes. With no active
68
+ // project (e.g. `/`) there are no tabs — keep the list empty and skip storage.
69
+ useEffect(() => {
70
+ if (!projectId) {
71
+ setTabs([])
72
+ setLoaded(false)
73
+ return
74
+ }
75
+ setLoaded(false)
76
+ try {
77
+ const raw = localStorage.getItem(storageKey(projectId))
78
+ const parsed = raw ? (JSON.parse(raw) as Tab[]) : []
79
+ setTabs(
80
+ Array.isArray(parsed)
81
+ ? parsed.filter((t) => t && typeof t.path === 'string' && typeof t.title === 'string')
82
+ : [],
83
+ )
84
+ } catch {
85
+ setTabs([])
86
+ }
87
+ setLoaded(true)
88
+ }, [projectId])
89
+
90
+ // Persist on change (only after the initial load, so we don't clobber stored
91
+ // tabs with the empty initial state before the load effect has run).
92
+ useEffect(() => {
93
+ if (!loaded || !projectId) return
94
+ try {
95
+ localStorage.setItem(storageKey(projectId), JSON.stringify(tabs))
96
+ } catch {
97
+ /* ignore quota/availability errors */
98
+ }
99
+ }, [tabs, projectId, loaded])
100
+
101
+ const openTab = useCallback((path: string, title: string) => {
102
+ setTabs((prev) => {
103
+ const norm = normTabPath(path)
104
+ if (prev.some((t) => normTabPath(t.path) === norm)) return prev
105
+ return [...prev, { path, title }]
106
+ })
107
+ }, [])
108
+
109
+ const closeTab = useCallback((path: string) => {
110
+ const norm = normTabPath(path)
111
+ setTabs((prev) => prev.filter((t) => normTabPath(t.path) !== norm))
112
+ }, [])
113
+
114
+ const closeAll = useCallback(() => setTabs([]), [])
115
+
116
+ const value = useMemo<TabsContextValue>(
117
+ () => ({ tabs, openTab, closeTab, closeAll, hasTabs: tabs.length > 0 }),
118
+ [tabs, openTab, closeTab, closeAll],
119
+ )
120
+
121
+ return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>
122
+ }
123
+
124
+ export function useTabs(): TabsContextValue {
125
+ const ctx = useContext(TabsContext)
126
+ if (!ctx) throw new Error('useTabs must be used within a TabsProvider')
127
+ return ctx
128
+ }