@oneie/plugin-docs 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 ADDED
@@ -0,0 +1,86 @@
1
+ # ONE License (Version 1.0)
2
+
3
+ Copyright (c) 2024-2026 one.ie
4
+
5
+ ## Maximum Freedom, One Obligation
6
+
7
+ This license empowers you with complete commercial freedom to use, reuse, modify, sell and resell the software, AI and data, at any price you wish.
8
+
9
+ No usage limits. No royalty fees. Just pure, unrestricted ability to innovate and profit from the software.
10
+
11
+ You are free to run ONE locally, on your own servers, in the cloud and at the edge.
12
+
13
+ ## You Receive
14
+
15
+ ### Unlimited Rights
16
+
17
+ You have unrestricted rights to use, modify, license, sublicense, distribute, sell, resell and monetize the Software without restrictions, subject only to the Brand Requirement below.
18
+
19
+ ### Permitted Actions
20
+
21
+ Including, but not limited to:
22
+
23
+ - Commercial use and integration into any systems
24
+ - Creation and sale of derivative works
25
+ - Providing software as a service
26
+ - AI training and content generation
27
+ - Patenting innovations based on the Software
28
+ - Open source applications and integrations
29
+
30
+ ### License Compatibility
31
+
32
+ This license is compatible with all major open-source licenses, including:
33
+
34
+ - MIT License
35
+ - Apache License
36
+ - GNU General Public License (GPL)
37
+ - BSD Licenses
38
+ - Mozilla Public License
39
+
40
+ ### Perpetual Rights
41
+
42
+ These rights are granted in perpetuity and are irrevocable, provided the Brand Requirement is maintained.
43
+
44
+ ## Intellectual Property
45
+
46
+ - We retain ownership of the original Software and data.
47
+ - You own any modifications you make.
48
+ - You never have to share your code or data.
49
+
50
+ ## Brand Requirement
51
+
52
+ The only obligation is:
53
+
54
+ - Don't remove the ONE brand, logo, and link to https://one.ie/ from the deployed product.
55
+
56
+ To remove the Brand Requirement — white-label, no visible ONE branding — obtain the
57
+ **ONE Enterprise License** (see `LICENSE-ENTERPRISE.md` or contact agent@one.ie).
58
+
59
+ ## Liability and Warranty
60
+
61
+ The Software and data is provided "AS IS". We bear no liability for its use.
62
+
63
+ ## Termination
64
+
65
+ This license terminates if you remove or hide the ONE brand, logo, or link without
66
+ holding a current ONE Enterprise License.
67
+
68
+ ## Governing Law and Disputes
69
+
70
+ This license is governed by the laws of Ireland. The parties will attempt to resolve disputes through good-faith negotiation. If necessary, disputes will proceed to mediation under the Mediators' Institute of Ireland rules, and then to binding arbitration under the Arbitration Act 2010, seated in Dublin, conducted in English.
71
+
72
+ ---
73
+
74
+ This license is designed to maximize freedom to innovate and profit. There is no copyleft requirement to share any code, making it suitable for enterprise use.
75
+
76
+ ## Enterprise Solutions
77
+
78
+ Building something big? We're here to help:
79
+
80
+ - **Free** — use every feature with the ONE brand link in the footer
81
+ - **White-label** — remove the brand requirement (ONE Enterprise License)
82
+ - **Custom** — white-label solutions tailored to your needs
83
+ - **Enterprise** — full support and deployment assistance
84
+ - **Training** — help getting your team started
85
+
86
+ Contact agent@one.ie to share your needs · Learn at https://one.ie/learn · Agents: https://one.ie/llms.txt
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # @oneie/plugin-docs
2
+
3
+ ONE docs plugin — Astro content collection with sidebar nav, search, code blocks, and versioning. Ships full source to your repo (free tier).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @oneie/plugin-docs
9
+ ```
10
+
11
+ ## one.config.ts
12
+
13
+ ```ts
14
+ import { defineOne } from '@oneie/frontend'
15
+ import { docs } from '@oneie/plugin-docs'
16
+
17
+ export default defineOne({
18
+ plugins: [
19
+ docs({
20
+ docsDir: 'src/content/docs', // default
21
+ injectRoutes: true, // default
22
+ editBaseUrl: 'https://github.com/your-org/your-repo/edit/main',
23
+ sidebar: [
24
+ { folder: 'getting-started', label: 'Getting Started', icon: '🚀' },
25
+ { folder: 'guides', label: 'Guides', icon: '📖' },
26
+ { folder: 'reference', label: 'Reference', icon: '📐' },
27
+ ],
28
+ }),
29
+ ],
30
+ })
31
+ ```
32
+
33
+ ## Content collection schema
34
+
35
+ Create `src/content/config.ts` (or add to your existing one):
36
+
37
+ ```ts
38
+ import { defineCollection, z } from 'astro:content'
39
+
40
+ const docs = defineCollection({
41
+ type: 'content',
42
+ schema: z.object({
43
+ title: z.string(),
44
+ description: z.string().optional(),
45
+ /** Numeric sort order within the folder. Lower = first. */
46
+ order: z.number().optional(),
47
+ /** Optional category tag for filtering */
48
+ category: z.string().optional(),
49
+ }),
50
+ })
51
+
52
+ export const collections = { docs }
53
+ ```
54
+
55
+ Files live at `src/content/docs/**/*.md` or `.mdx`. The first path segment becomes the sidebar folder:
56
+
57
+ ```
58
+ src/content/docs/
59
+ getting-started/
60
+ introduction.md # slug: getting-started/introduction
61
+ installation.md # slug: getting-started/installation
62
+ guides/
63
+ deployment.md # slug: guides/deployment
64
+ reference/
65
+ api.md # slug: reference/api
66
+ ```
67
+
68
+ ## Usage — [slug].astro
69
+
70
+ Create `src/pages/docs/[slug].astro`:
71
+
72
+ ```astro
73
+ ---
74
+ import { getCollection, getEntry } from 'astro:content'
75
+ import DocsLayout from '@oneie/plugin-docs/DocsLayout.astro'
76
+
77
+ export async function getStaticPaths() {
78
+ const docs = await getCollection('docs')
79
+ return docs.map((entry) => ({
80
+ params: { slug: entry.id },
81
+ props: { entry },
82
+ }))
83
+ }
84
+
85
+ const { entry } = Astro.props
86
+ const allDocs = await getCollection('docs')
87
+ const { Content, headings } = await entry.render()
88
+ ---
89
+
90
+ <DocsLayout
91
+ entry={entry}
92
+ entries={allDocs}
93
+ headings={headings}
94
+ editBaseUrl="https://github.com/your-org/your-repo/edit/main/src/content/docs"
95
+ sidebar={[
96
+ { folder: 'getting-started', label: 'Getting Started' },
97
+ { folder: 'guides', label: 'Guides' },
98
+ { folder: 'reference', label: 'Reference' },
99
+ ]}
100
+ >
101
+ <Content />
102
+ </DocsLayout>
103
+ ```
104
+
105
+ ## Docs landing page — /docs/index.astro
106
+
107
+ ```astro
108
+ ---
109
+ import DocsIndex from '@oneie/plugin-docs/DocsIndex.astro'
110
+ ---
111
+
112
+ <DocsIndex
113
+ title="Documentation"
114
+ description="Everything you need to get up and running."
115
+ />
116
+ ```
117
+
118
+ ## Sidebar order
119
+
120
+ Folders render in the order declared in the `sidebar` array. Entries within each folder sort by `order` (frontmatter) then alphabetically by title. Folders not listed in `sidebar` append after the configured ones.
121
+
122
+ ## editBaseUrl — GitHub edit links
123
+
124
+ Set `editBaseUrl` to the raw path of your docs directory on GitHub:
125
+
126
+ ```
127
+ https://github.com/your-org/your-repo/edit/main/src/content/docs
128
+ ```
129
+
130
+ An "Edit this page on GitHub" link appears at the bottom of every doc, pointing to `editBaseUrl/entry.id`.
131
+
132
+ ## Components (standalone use)
133
+
134
+ ```tsx
135
+ import { DocSearch } from '@oneie/plugin-docs/DocSearch.tsx'
136
+ import { SidebarDocs } from '@oneie/plugin-docs/SidebarDocs.tsx'
137
+ import { CodeBlock } from '@oneie/plugin-docs/CodeBlock.tsx'
138
+
139
+ // Search bar
140
+ <DocSearch value={currentSearch} placeholder="Search…" />
141
+
142
+ // Sidebar (requires React state for collapsible folders)
143
+ <SidebarDocs entries={entries} currentSlug={slug} sidebar={sidebarConfig} />
144
+
145
+ // Code block with copy button
146
+ <CodeBlock code={`const x = 1`} language="ts" filename="example.ts" />
147
+ ```
148
+
149
+ ## License
150
+
151
+ ONE License — full commercial use, keep the ONE brand link. See [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@oneie/plugin-docs",
3
+ "version": "0.1.0",
4
+ "description": "ONE docs — Astro content collection with sidebar nav, search, code blocks, and versioning",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./DocSearch.tsx": "./src/DocSearch.tsx",
11
+ "./SidebarDocs.tsx": "./src/SidebarDocs.tsx",
12
+ "./CodeBlock.tsx": "./src/CodeBlock.tsx",
13
+ "./DocsLayout.astro": "./src/DocsLayout.astro",
14
+ "./DocsIndex.astro": "./src/DocsIndex.astro"
15
+ },
16
+ "peerDependencies": {
17
+ "astro": ">=6.0.0",
18
+ "@oneie/frontend": "*",
19
+ "react": ">=19.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "astro": "^6.2.2",
23
+ "react": "^19.0.0",
24
+ "typescript": "^5.7.3"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "@oneie/frontend": {
28
+ "optional": true
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,75 @@
1
+ import { useState } from 'react'
2
+ import { Copy, Check } from 'lucide-react'
3
+
4
+ export interface CodeBlockProps {
5
+ code: string
6
+ language?: string
7
+ filename?: string
8
+ }
9
+
10
+ /**
11
+ * CodeBlock — dark code block with optional filename tab, language badge, and copy button.
12
+ * Copy state resets after 2 seconds.
13
+ */
14
+ export function CodeBlock({ code, language, filename }: CodeBlockProps) {
15
+ const [copied, setCopied] = useState(false)
16
+
17
+ async function handleCopy() {
18
+ try {
19
+ await navigator.clipboard.writeText(code)
20
+ setCopied(true)
21
+ setTimeout(() => setCopied(false), 2000)
22
+ } catch {
23
+ // Clipboard API unavailable — silently no-op
24
+ }
25
+ }
26
+
27
+ return (
28
+ <div className="group relative my-4 overflow-hidden rounded-lg border border-slate-800 bg-slate-950 text-sm">
29
+ {/* Top bar: filename tab + language badge */}
30
+ {(filename || language) && (
31
+ <div className="flex items-center justify-between border-b border-slate-800 px-4 py-2">
32
+ {filename ? (
33
+ <span className="font-mono text-xs text-slate-400">{filename}</span>
34
+ ) : (
35
+ <span />
36
+ )}
37
+ {language && (
38
+ <span className="rounded bg-slate-800 px-2 py-0.5 font-mono text-xs text-slate-400">
39
+ {language}
40
+ </span>
41
+ )}
42
+ </div>
43
+ )}
44
+
45
+ {/* Language badge when no filename bar */}
46
+ {!filename && !language && null}
47
+
48
+ {/* Code content */}
49
+ <div className="relative overflow-x-auto p-4">
50
+ <pre className="m-0 font-mono text-sm leading-relaxed text-slate-100">
51
+ <code>{code}</code>
52
+ </pre>
53
+
54
+ {/* Copy button — appears on group hover */}
55
+ <button
56
+ onClick={handleCopy}
57
+ aria-label={copied ? 'Copied' : 'Copy code'}
58
+ className="absolute right-3 top-3 flex items-center gap-1.5 rounded border border-slate-700 bg-slate-800 px-2 py-1 text-xs text-slate-300 opacity-0 transition-all hover:bg-slate-700 hover:text-white group-hover:opacity-100"
59
+ >
60
+ {copied ? (
61
+ <>
62
+ <Check className="h-3.5 w-3.5 text-green-400" />
63
+ <span>Copied</span>
64
+ </>
65
+ ) : (
66
+ <>
67
+ <Copy className="h-3.5 w-3.5" />
68
+ <span>Copy</span>
69
+ </>
70
+ )}
71
+ </button>
72
+ </div>
73
+ </div>
74
+ )
75
+ }
@@ -0,0 +1,39 @@
1
+ import { Search } from 'lucide-react'
2
+
3
+ export interface DocSearchProps {
4
+ value?: string
5
+ placeholder?: string
6
+ }
7
+
8
+ /**
9
+ * DocSearch — form-based full-text search for the docs collection.
10
+ * Submits to /docs with ?search= preserving view/folder/tag params.
11
+ */
12
+ export function DocSearch({ value = '', placeholder = 'Search docs…' }: DocSearchProps) {
13
+ function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
14
+ e.preventDefault()
15
+ const form = e.currentTarget
16
+ const input = form.elements.namedItem('search') as HTMLInputElement
17
+ const params = new URLSearchParams(window.location.search)
18
+ params.set('search', input.value)
19
+ // Preserve view/folder/tag but reset pagination
20
+ params.delete('page')
21
+ window.location.href = `/docs?${params.toString()}`
22
+ }
23
+
24
+ return (
25
+ <form onSubmit={handleSubmit} className="relative w-full max-w-sm">
26
+ <span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-muted-foreground">
27
+ <Search className="h-4 w-4" />
28
+ </span>
29
+ <input
30
+ type="search"
31
+ name="search"
32
+ defaultValue={value}
33
+ placeholder={placeholder}
34
+ className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
35
+ aria-label="Search documentation"
36
+ />
37
+ </form>
38
+ )
39
+ }
@@ -0,0 +1,119 @@
1
+ ---
2
+ import { getCollection } from 'astro:content'
3
+
4
+ export interface Props {
5
+ title?: string
6
+ description?: string
7
+ }
8
+
9
+ const {
10
+ title = 'Documentation',
11
+ description = 'Everything you need to get started.',
12
+ } = Astro.props
13
+
14
+ // Fetch all docs and group by folder
15
+ const allDocs = await getCollection('docs')
16
+
17
+ // Sort by order then title within each folder
18
+ allDocs.sort((a, b) => {
19
+ const ao = (a.data as { order?: number }).order ?? 999
20
+ const bo = (b.data as { order?: number }).order ?? 999
21
+ if (ao !== bo) return ao - bo
22
+ return a.data.title.localeCompare(b.data.title)
23
+ })
24
+
25
+ // Group by first path segment
26
+ const groups: Record<string, typeof allDocs> = {}
27
+ for (const doc of allDocs) {
28
+ const folder = doc.id.includes('/') ? doc.id.split('/')[0] : '_root'
29
+ if (!groups[folder]) groups[folder] = []
30
+ groups[folder].push(doc)
31
+ }
32
+
33
+ const folderOrder = Object.keys(groups).filter((f) => f !== '_root')
34
+ const rootDocs = groups['_root'] ?? []
35
+
36
+ function folderLabel(key: string): string {
37
+ return key.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
38
+ }
39
+ ---
40
+
41
+ <!doctype html>
42
+ <html lang="en">
43
+ <head>
44
+ <meta charset="UTF-8" />
45
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
46
+ <title>{title}</title>
47
+ {description && <meta name="description" content={description} />}
48
+ <slot name="head" />
49
+ </head>
50
+ <body class="bg-background text-foreground antialiased">
51
+ <slot name="header" />
52
+
53
+ <!-- Hero -->
54
+ <section class="border-b border-border bg-muted/30 px-6 py-16 text-center">
55
+ <h1 class="text-4xl font-bold tracking-tight">{title}</h1>
56
+ {description && (
57
+ <p class="mx-auto mt-4 max-w-xl text-lg text-muted-foreground">{description}</p>
58
+ )}
59
+ </section>
60
+
61
+ <!-- Doc groups grid -->
62
+ <main class="mx-auto max-w-screen-xl px-6 py-12">
63
+ <!-- Root-level docs (no folder) -->
64
+ {rootDocs.length > 0 && (
65
+ <div class="mb-10">
66
+ <ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
67
+ {rootDocs.map((doc) => (
68
+ <li>
69
+ <a
70
+ href={`/docs/${doc.id}`}
71
+ class="block rounded-lg border border-border bg-card p-4 text-sm hover:border-primary hover:bg-accent transition-colors"
72
+ >
73
+ <span class="font-medium text-foreground">{doc.data.title}</span>
74
+ {doc.data.description && (
75
+ <span class="mt-1 block text-xs text-muted-foreground line-clamp-2">
76
+ {doc.data.description}
77
+ </span>
78
+ )}
79
+ </a>
80
+ </li>
81
+ ))}
82
+ </ul>
83
+ </div>
84
+ )}
85
+
86
+ <!-- Folder sections -->
87
+ <div class="space-y-12">
88
+ {folderOrder.map((folder) => {
89
+ const folderDocs = groups[folder]
90
+ if (!folderDocs?.length) return null
91
+ return (
92
+ <section>
93
+ <h2 class="mb-4 text-xl font-semibold text-foreground">{folderLabel(folder)}</h2>
94
+ <ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
95
+ {folderDocs.map((doc) => (
96
+ <li>
97
+ <a
98
+ href={`/docs/${doc.id}`}
99
+ class="block rounded-lg border border-border bg-card p-4 text-sm hover:border-primary hover:bg-accent transition-colors"
100
+ >
101
+ <span class="font-medium text-foreground">{doc.data.title}</span>
102
+ {doc.data.description && (
103
+ <span class="mt-1 block text-xs text-muted-foreground line-clamp-2">
104
+ {doc.data.description}
105
+ </span>
106
+ )}
107
+ </a>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ </section>
112
+ )
113
+ })}
114
+ </div>
115
+ </main>
116
+
117
+ <slot name="footer" />
118
+ </body>
119
+ </html>
@@ -0,0 +1,118 @@
1
+ ---
2
+ import type { CollectionEntry, MarkdownHeading } from 'astro:content'
3
+ import { SidebarDocs } from './SidebarDocs.tsx'
4
+ import { DocSearch } from './DocSearch.tsx'
5
+
6
+ export interface Props {
7
+ entry: CollectionEntry<'docs'>
8
+ entries: CollectionEntry<'docs'>[]
9
+ headings?: MarkdownHeading[]
10
+ editBaseUrl?: string
11
+ sidebar?: Array<{ folder: string; label: string; icon?: string }>
12
+ }
13
+
14
+ const { entry, entries, headings = [], editBaseUrl, sidebar } = Astro.props
15
+
16
+ const searchQuery = Astro.url.searchParams.get('search') ?? ''
17
+
18
+ // Flatten entries for the sidebar component
19
+ const sidebarEntries = entries.map((e) => ({
20
+ slug: e.id,
21
+ data: { title: e.data.title, order: (e.data as { order?: number }).order },
22
+ }))
23
+
24
+ const editUrl = editBaseUrl ? `${editBaseUrl.replace(/\/$/, '')}/${entry.id}` : null
25
+
26
+ // Filter headings to h2/h3 for TOC
27
+ const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3)
28
+ ---
29
+
30
+ <!doctype html>
31
+ <html lang="en" class="h-full">
32
+ <head>
33
+ <meta charset="UTF-8" />
34
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
35
+ <title>{entry.data.title}</title>
36
+ {entry.data.description && <meta name="description" content={entry.data.description} />}
37
+ <slot name="head" />
38
+ </head>
39
+ <body class="h-full bg-background text-foreground antialiased">
40
+ <!-- Top bar -->
41
+ <header class="sticky top-0 z-40 flex h-14 items-center gap-4 border-b border-border bg-background/95 px-6 backdrop-blur">
42
+ <a href="/docs" class="mr-4 text-sm font-semibold text-foreground hover:text-primary">
43
+ Docs
44
+ </a>
45
+ <div class="flex-1">
46
+ <DocSearch client:load value={searchQuery} />
47
+ </div>
48
+ <slot name="header-actions" />
49
+ </header>
50
+
51
+ <!-- Two-column layout -->
52
+ <div class="mx-auto flex max-w-screen-xl">
53
+ <!-- Sidebar: 280px fixed width -->
54
+ <aside class="sticky top-14 hidden h-[calc(100vh-3.5rem)] w-[280px] shrink-0 overflow-hidden border-r border-border lg:block">
55
+ <SidebarDocs
56
+ client:load
57
+ entries={sidebarEntries}
58
+ currentSlug={entry.id}
59
+ sidebar={sidebar}
60
+ />
61
+ </aside>
62
+
63
+ <!-- Main content -->
64
+ <main class="min-w-0 flex-1 px-8 py-10">
65
+ <div class="flex gap-8">
66
+ <!-- Prose area -->
67
+ <article class="min-w-0 flex-1">
68
+ <h1 class="mb-2 text-3xl font-bold tracking-tight">{entry.data.title}</h1>
69
+ {entry.data.description && (
70
+ <p class="mb-8 text-lg text-muted-foreground">{entry.data.description}</p>
71
+ )}
72
+ <div class="prose prose-slate dark:prose-invert max-w-none">
73
+ <slot />
74
+ </div>
75
+
76
+ <!-- Edit this page -->
77
+ {editUrl && (
78
+ <div class="mt-12 border-t border-border pt-6">
79
+ <a
80
+ href={editUrl}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ class="text-sm text-muted-foreground hover:text-foreground"
84
+ >
85
+ Edit this page on GitHub →
86
+ </a>
87
+ </div>
88
+ )}
89
+ </article>
90
+
91
+ <!-- TOC: right panel, hidden on mobile -->
92
+ {tocHeadings.length > 0 && (
93
+ <nav
94
+ aria-label="On this page"
95
+ class="sticky top-20 hidden h-fit w-52 shrink-0 xl:block"
96
+ >
97
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
98
+ On this page
99
+ </p>
100
+ <ul class="space-y-1 text-sm">
101
+ {tocHeadings.map((h) => (
102
+ <li class={h.depth === 3 ? 'pl-3' : ''}>
103
+ <a
104
+ href={`#${h.slug}`}
105
+ class="block text-muted-foreground hover:text-foreground transition-colors py-0.5"
106
+ >
107
+ {h.text}
108
+ </a>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ </nav>
113
+ )}
114
+ </div>
115
+ </main>
116
+ </div>
117
+ </body>
118
+ </html>
@@ -0,0 +1,151 @@
1
+ import { useState } from 'react'
2
+ import { ChevronDown, ChevronRight } from 'lucide-react'
3
+
4
+ export interface DocEntry {
5
+ slug: string
6
+ data: {
7
+ title: string
8
+ order?: number
9
+ }
10
+ }
11
+
12
+ export interface SidebarFolder {
13
+ folder: string
14
+ label: string
15
+ icon?: string
16
+ }
17
+
18
+ export interface SidebarDocsProps {
19
+ entries: DocEntry[]
20
+ currentSlug: string
21
+ sidebar?: SidebarFolder[]
22
+ }
23
+
24
+ function groupByFolder(entries: DocEntry[]): Record<string, DocEntry[]> {
25
+ const groups: Record<string, DocEntry[]> = {}
26
+ for (const entry of entries) {
27
+ const folder = entry.slug.includes('/') ? entry.slug.split('/')[0] : '_root'
28
+ if (!groups[folder]) groups[folder] = []
29
+ groups[folder].push(entry)
30
+ }
31
+ // Sort entries within each folder by order then title
32
+ for (const folder of Object.keys(groups)) {
33
+ groups[folder].sort((a, b) => {
34
+ const ao = a.data.order ?? 999
35
+ const bo = b.data.order ?? 999
36
+ if (ao !== bo) return ao - bo
37
+ return a.data.title.localeCompare(b.data.title)
38
+ })
39
+ }
40
+ return groups
41
+ }
42
+
43
+ function FolderSection({
44
+ folderKey,
45
+ label,
46
+ icon,
47
+ entries,
48
+ currentSlug,
49
+ }: {
50
+ folderKey: string
51
+ label: string
52
+ icon?: string
53
+ entries: DocEntry[]
54
+ currentSlug: string
55
+ }) {
56
+ const hasActive = entries.some((e) => e.slug === currentSlug)
57
+ const [open, setOpen] = useState(hasActive || folderKey === '_root')
58
+
59
+ return (
60
+ <div className="mb-1">
61
+ {folderKey !== '_root' && (
62
+ <button
63
+ onClick={() => setOpen((o) => !o)}
64
+ className="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-sm font-semibold text-foreground hover:bg-accent transition-colors"
65
+ aria-expanded={open}
66
+ >
67
+ {icon && <span aria-hidden="true">{icon}</span>}
68
+ <span className="flex-1 text-left">{label}</span>
69
+ {open ? (
70
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
71
+ ) : (
72
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
73
+ )}
74
+ </button>
75
+ )}
76
+ {open && (
77
+ <ul className={folderKey !== '_root' ? 'ml-3 border-l border-border pl-2' : ''}>
78
+ {entries.map((entry) => {
79
+ const isActive = entry.slug === currentSlug
80
+ return (
81
+ <li key={entry.slug}>
82
+ <a
83
+ href={`/docs/${entry.slug}`}
84
+ className={[
85
+ 'block rounded px-2 py-1 text-sm transition-colors',
86
+ isActive
87
+ ? 'text-primary font-medium bg-accent'
88
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent',
89
+ ].join(' ')}
90
+ aria-current={isActive ? 'page' : undefined}
91
+ >
92
+ {entry.data.title}
93
+ </a>
94
+ </li>
95
+ )
96
+ })}
97
+ </ul>
98
+ )}
99
+ </div>
100
+ )
101
+ }
102
+
103
+ /**
104
+ * SidebarDocs — collapsible folder-grouped sidebar for the docs collection.
105
+ * Folder order follows the `sidebar` config array; unlisted folders append at the end.
106
+ */
107
+ export function SidebarDocs({ entries, currentSlug, sidebar = [] }: SidebarDocsProps) {
108
+ const groups = groupByFolder(entries)
109
+
110
+ // Build ordered folder list: sidebar config first, then remaining folders
111
+ const configuredFolders = sidebar.map((s) => s.folder)
112
+ const remainingFolders = Object.keys(groups).filter(
113
+ (f) => !configuredFolders.includes(f) && f !== '_root',
114
+ )
115
+ const orderedFolders = [...configuredFolders, ...remainingFolders]
116
+
117
+ // Root entries (no folder) render first without a header
118
+ const rootEntries = groups['_root'] ?? []
119
+
120
+ return (
121
+ <nav
122
+ aria-label="Documentation navigation"
123
+ className="h-full overflow-y-auto py-4 pr-2 text-sm"
124
+ >
125
+ {rootEntries.length > 0 && (
126
+ <FolderSection
127
+ folderKey="_root"
128
+ label=""
129
+ entries={rootEntries}
130
+ currentSlug={currentSlug}
131
+ />
132
+ )}
133
+ {orderedFolders.map((folderKey) => {
134
+ const folderEntries = groups[folderKey]
135
+ if (!folderEntries?.length) return null
136
+ const config = sidebar.find((s) => s.folder === folderKey)
137
+ const label = config?.label ?? folderKey.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
138
+ return (
139
+ <FolderSection
140
+ key={folderKey}
141
+ folderKey={folderKey}
142
+ label={label}
143
+ icon={config?.icon}
144
+ entries={folderEntries}
145
+ currentSlug={currentSlug}
146
+ />
147
+ )
148
+ })}
149
+ </nav>
150
+ )
151
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { OnePluginFactory } from '@oneie/frontend'
2
+
3
+ export interface SidebarFolder {
4
+ folder: string
5
+ label: string
6
+ icon?: string
7
+ }
8
+
9
+ export interface OneDocsConfig {
10
+ /** Directory where docs content lives. Default: 'src/content/docs' */
11
+ docsDir?: string
12
+ /** Whether to inject /docs and /docs/[slug] routes automatically. Default: true */
13
+ injectRoutes?: boolean
14
+ /** User-defined folder order and labels for the sidebar */
15
+ sidebar?: SidebarFolder[]
16
+ /** Base URL for "Edit this page" GitHub links, e.g. 'https://github.com/org/repo/edit/main' */
17
+ editBaseUrl?: string
18
+ }
19
+
20
+ export const docs: OnePluginFactory<OneDocsConfig> = (config = {}) => ({
21
+ name: 'plugin-docs',
22
+ tier: 'free',
23
+ config: undefined,
24
+ integration: undefined,
25
+ entitlement: undefined,
26
+ })
27
+
28
+ export type { OneDocsConfig as DocsConfig, SidebarFolder }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "jsxImportSource": "react",
6
+ "paths": {
7
+ "@/*": ["../../apps/web/src/*"]
8
+ }
9
+ },
10
+ "include": ["src"]
11
+ }