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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/app/components/CanvasMount.tsx +62 -0
- package/app/components/CodeWrapToggle.tsx +78 -0
- package/app/components/FindOnPage.tsx +224 -0
- package/app/components/MobileBottomBar.tsx +93 -0
- package/app/components/MobileProjectsPanel.tsx +113 -0
- package/app/components/PageFloatingMenu.tsx +224 -0
- package/app/components/ProjectSwitcher.tsx +124 -0
- package/app/components/Search.tsx +930 -0
- package/app/components/ShortcutsHelp.tsx +113 -0
- package/app/components/Sidebar.tsx +1049 -0
- package/app/components/TabBar.tsx +227 -0
- package/app/components/Toc.tsx +129 -0
- package/app/components/TopBar.tsx +74 -0
- package/app/components/theme-toggle.tsx +71 -0
- package/app/components/ui/button.tsx +56 -0
- package/app/components/ui/card.tsx +55 -0
- package/app/components/ui/dropdown-menu.tsx +156 -0
- package/app/components/ui/input.tsx +21 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +155 -0
- package/app/generated/site.ts +19 -0
- package/app/generated/slots.ts +10 -0
- package/app/generated/theme.generated.css +60 -0
- package/app/lib/config/config.server.ts +50 -0
- package/app/lib/config/defaults.ts +120 -0
- package/app/lib/config/load.ts +82 -0
- package/app/lib/config/schema.ts +131 -0
- package/app/lib/config/site.ts +43 -0
- package/app/lib/content.server.ts +105 -0
- package/app/lib/projects.ts +86 -0
- package/app/lib/sidebar.server.ts +113 -0
- package/app/lib/site.ts +27 -0
- package/app/lib/slots.tsx +33 -0
- package/app/lib/tabs.tsx +128 -0
- package/app/lib/useKeyboardShortcuts.ts +149 -0
- package/app/lib/utils.ts +17 -0
- package/app/root.tsx +171 -0
- package/app/routes/$.tsx +158 -0
- package/app/routes/_index.tsx +60 -0
- package/app/styles/app.css +461 -0
- package/app/styles/obsidian.css +83 -0
- package/app/styles/tailwind.css +227 -0
- package/cli.js +119 -0
- package/components.json +21 -0
- package/dist/config.mjs +87 -0
- package/dist/generate-content.mjs +1665 -0
- package/package.json +112 -0
- package/scripts/build-search-index.ts +129 -0
- package/scripts/canonical.ts +34 -0
- package/scripts/canvas-to-md.ts +73 -0
- package/scripts/compile.ts +242 -0
- package/scripts/emit-config.ts +163 -0
- package/scripts/generate-content.ts +197 -0
- package/scripts/obsidian/files.ts +222 -0
- package/scripts/obsidian/fs.ts +34 -0
- package/scripts/obsidian/generate.ts +36 -0
- package/scripts/obsidian/html.ts +17 -0
- package/scripts/obsidian/logger.ts +10 -0
- package/scripts/obsidian/markdown.ts +56 -0
- package/scripts/obsidian/obsidian.ts +229 -0
- package/scripts/obsidian/path.ts +60 -0
- package/scripts/obsidian/rehype.ts +60 -0
- package/scripts/obsidian/remark.ts +712 -0
- package/scripts/obsidian/types.ts +31 -0
- 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
|
+
}
|
package/app/lib/site.ts
ADDED
|
@@ -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
|
package/app/lib/tabs.tsx
ADDED
|
@@ -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
|
+
}
|