@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 +86 -0
- package/README.md +151 -0
- package/package.json +31 -0
- package/src/CodeBlock.tsx +75 -0
- package/src/DocSearch.tsx +39 -0
- package/src/DocsIndex.astro +119 -0
- package/src/DocsLayout.astro +118 -0
- package/src/SidebarDocs.tsx +151 -0
- package/src/index.ts +28 -0
- package/tsconfig.json +11 -0
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 }
|