@oneie/plugin-blog 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 +134 -0
- package/package.json +30 -0
- package/src/BlogIndex.astro +41 -0
- package/src/BlogPost.astro +96 -0
- package/src/BlogSearch.tsx +70 -0
- package/src/PostCard.tsx +83 -0
- package/src/index.ts +53 -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,134 @@
|
|
|
1
|
+
# @oneie/plugin-blog
|
|
2
|
+
|
|
3
|
+
ONE blog — Astro content collection with post grid, client-side search, RSS, and MDX support. Source ships to your repo (free tier).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @oneie/plugin-blog
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
### 1. Register the plugin
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// one.config.ts
|
|
17
|
+
import { defineOne } from '@oneie/frontend'
|
|
18
|
+
import { blog } from '@oneie/plugin-blog'
|
|
19
|
+
|
|
20
|
+
export default defineOne({
|
|
21
|
+
plugins: [
|
|
22
|
+
blog({
|
|
23
|
+
postsDir: 'src/content/blog', // default
|
|
24
|
+
rss: true, // emit /rss.xml
|
|
25
|
+
injectRoutes: true, // add /blog and /blog/[slug]
|
|
26
|
+
postsPerPage: 12,
|
|
27
|
+
}),
|
|
28
|
+
],
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Define the content collection
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// src/content/config.ts
|
|
36
|
+
import { defineCollection, z } from 'astro:content'
|
|
37
|
+
|
|
38
|
+
const blog = defineCollection({
|
|
39
|
+
type: 'content',
|
|
40
|
+
schema: z.object({
|
|
41
|
+
title: z.string(),
|
|
42
|
+
description: z.string(),
|
|
43
|
+
date: z.coerce.date(),
|
|
44
|
+
category: z.string().optional(),
|
|
45
|
+
image: z.string().optional(),
|
|
46
|
+
tags: z.array(z.string()).optional(),
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export const collections = { blog }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 3. Write posts
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
src/content/blog/
|
|
57
|
+
hello-world.mdx
|
|
58
|
+
second-post.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each file's frontmatter:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
---
|
|
65
|
+
title: Hello World
|
|
66
|
+
description: My first post on the ONE blog plugin.
|
|
67
|
+
date: 2026-01-15
|
|
68
|
+
category: Updates
|
|
69
|
+
image: /images/hello.jpg
|
|
70
|
+
tags: [one, astro, blog]
|
|
71
|
+
---
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Using the components directly
|
|
75
|
+
|
|
76
|
+
If you set `injectRoutes: false` (or want to customise the pages), import the components into your own pages.
|
|
77
|
+
|
|
78
|
+
### BlogIndex page
|
|
79
|
+
|
|
80
|
+
```astro
|
|
81
|
+
---
|
|
82
|
+
// src/pages/blog/index.astro
|
|
83
|
+
import BlogIndex from '@oneie/plugin-blog/BlogIndex.astro'
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
<BlogIndex title="Our Blog" description="The latest from the team." />
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### BlogPost page
|
|
90
|
+
|
|
91
|
+
```astro
|
|
92
|
+
---
|
|
93
|
+
// src/pages/blog/[slug].astro
|
|
94
|
+
import { getCollection } from 'astro:content'
|
|
95
|
+
import BlogPost from '@oneie/plugin-blog/BlogPost.astro'
|
|
96
|
+
|
|
97
|
+
export async function getStaticPaths() {
|
|
98
|
+
const posts = await getCollection('blog')
|
|
99
|
+
return posts.map((entry) => ({
|
|
100
|
+
params: { slug: entry.slug },
|
|
101
|
+
props: { entry },
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { entry } = Astro.props
|
|
106
|
+
const { headings } = await entry.render()
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
<BlogPost entry={entry} headings={headings} />
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### PostCard and BlogSearch
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { PostCard } from '@oneie/plugin-blog/PostCard.tsx'
|
|
116
|
+
import { BlogSearch } from '@oneie/plugin-blog/BlogSearch.tsx'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## RSS
|
|
120
|
+
|
|
121
|
+
When `rss: true` (the default), the plugin wires an RSS feed at `/rss.xml` via Astro's built-in RSS support. Link it in your `<head>`:
|
|
122
|
+
|
|
123
|
+
```html
|
|
124
|
+
<link rel="alternate" type="application/rss+xml" title="Blog RSS" href="/rss.xml" />
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Options
|
|
128
|
+
|
|
129
|
+
| Option | Type | Default | Description |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `postsDir` | `string` | `'src/content/blog'` | Directory for blog MDX/MD files |
|
|
132
|
+
| `rss` | `boolean` | `true` | Emit an RSS feed at `/rss.xml` |
|
|
133
|
+
| `injectRoutes` | `boolean` | `true` | Auto-add `/blog` and `/blog/[slug]` routes |
|
|
134
|
+
| `postsPerPage` | `number` | `12` | Posts shown per page in the index grid |
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oneie/plugin-blog",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ONE blog — Astro content collection with post grid, search, RSS, and MDX support",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./PostCard.tsx": "./src/PostCard.tsx",
|
|
11
|
+
"./BlogSearch.tsx": "./src/BlogSearch.tsx",
|
|
12
|
+
"./BlogIndex.astro": "./src/BlogIndex.astro",
|
|
13
|
+
"./BlogPost.astro": "./src/BlogPost.astro"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"astro": ">=6.0.0",
|
|
17
|
+
"@oneie/frontend": "*",
|
|
18
|
+
"react": ">=19.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"astro": "^6.2.2",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"typescript": "^5.7.3"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"@oneie/frontend": {
|
|
27
|
+
"optional": true
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from 'astro:content'
|
|
3
|
+
import { BlogSearch } from './BlogSearch'
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
title?: string
|
|
7
|
+
description?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
title = 'Blog',
|
|
12
|
+
description = 'Thoughts, updates, and insights.',
|
|
13
|
+
} = Astro.props
|
|
14
|
+
|
|
15
|
+
const rawPosts = await getCollection('blog')
|
|
16
|
+
|
|
17
|
+
const posts = rawPosts
|
|
18
|
+
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
|
19
|
+
.map((entry) => ({
|
|
20
|
+
title: entry.data.title,
|
|
21
|
+
description: entry.data.description,
|
|
22
|
+
slug: entry.slug,
|
|
23
|
+
date: entry.data.date,
|
|
24
|
+
category: entry.data.category,
|
|
25
|
+
image: entry.data.image,
|
|
26
|
+
tags: entry.data.tags,
|
|
27
|
+
}))
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<section class="relative overflow-hidden py-20 sm:py-28">
|
|
31
|
+
<div class="absolute inset-0 -z-10 bg-gradient-to-b from-muted/60 to-background" />
|
|
32
|
+
<div class="container mx-auto px-4 text-center">
|
|
33
|
+
<p class="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-3">Blog</p>
|
|
34
|
+
<h1 class="text-4xl sm:text-5xl font-bold tracking-tight mb-4">{title}</h1>
|
|
35
|
+
<p class="text-lg text-muted-foreground max-w-xl mx-auto">{description}</p>
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<section class="container mx-auto px-4 pb-24">
|
|
40
|
+
<BlogSearch client:load posts={posts} />
|
|
41
|
+
</section>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CollectionEntry } from 'astro:content'
|
|
3
|
+
import type { MarkdownHeading } from 'astro'
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
entry: CollectionEntry<'blog'>
|
|
7
|
+
headings?: MarkdownHeading[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { entry, headings = [] } = Astro.props
|
|
11
|
+
const { Content } = await entry.render()
|
|
12
|
+
|
|
13
|
+
const { title, description, date, category, image } = entry.data
|
|
14
|
+
|
|
15
|
+
const formattedDate = date.toLocaleDateString('en-US', {
|
|
16
|
+
year: 'numeric',
|
|
17
|
+
month: 'long',
|
|
18
|
+
day: 'numeric',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const tocHeadings = headings.filter((h) => h.depth <= 3)
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<div class="container mx-auto px-4 py-12 max-w-6xl">
|
|
25
|
+
<!-- Back link -->
|
|
26
|
+
<a
|
|
27
|
+
href="/blog"
|
|
28
|
+
class="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mb-8"
|
|
29
|
+
>
|
|
30
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
31
|
+
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
|
32
|
+
</svg>
|
|
33
|
+
Back to blog
|
|
34
|
+
</a>
|
|
35
|
+
|
|
36
|
+
<div class="lg:grid lg:grid-cols-[1fr_240px] lg:gap-12">
|
|
37
|
+
<!-- Main content -->
|
|
38
|
+
<article>
|
|
39
|
+
{image && (
|
|
40
|
+
<div class="aspect-video overflow-hidden rounded-xl mb-8">
|
|
41
|
+
<img src={image} alt={title} class="w-full h-full object-cover" />
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
<header class="mb-8">
|
|
46
|
+
{category && (
|
|
47
|
+
<span class="inline-block text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
|
|
48
|
+
{category}
|
|
49
|
+
</span>
|
|
50
|
+
)}
|
|
51
|
+
<h1 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">{title}</h1>
|
|
52
|
+
{description && (
|
|
53
|
+
<p class="text-lg text-muted-foreground mb-4">{description}</p>
|
|
54
|
+
)}
|
|
55
|
+
<div class="flex items-center gap-4 text-sm text-muted-foreground border-b pb-6">
|
|
56
|
+
<time datetime={date.toISOString()}>{formattedDate}</time>
|
|
57
|
+
</div>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<div class="prose prose-neutral dark:prose-invert max-w-none">
|
|
61
|
+
<Content />
|
|
62
|
+
</div>
|
|
63
|
+
</article>
|
|
64
|
+
|
|
65
|
+
<!-- TOC sidebar -->
|
|
66
|
+
{tocHeadings.length > 0 && (
|
|
67
|
+
<aside class="hidden lg:block">
|
|
68
|
+
<div class="sticky top-8">
|
|
69
|
+
<p class="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
|
|
70
|
+
On this page
|
|
71
|
+
</p>
|
|
72
|
+
<nav>
|
|
73
|
+
<ul class="space-y-1.5 text-sm">
|
|
74
|
+
{tocHeadings.map((heading) => (
|
|
75
|
+
<li
|
|
76
|
+
class:list={[
|
|
77
|
+
'leading-snug',
|
|
78
|
+
heading.depth === 2 && 'pl-0',
|
|
79
|
+
heading.depth === 3 && 'pl-3',
|
|
80
|
+
]}
|
|
81
|
+
>
|
|
82
|
+
<a
|
|
83
|
+
href={`#${heading.slug}`}
|
|
84
|
+
class="text-muted-foreground hover:text-foreground transition-colors"
|
|
85
|
+
>
|
|
86
|
+
{heading.text}
|
|
87
|
+
</a>
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
</nav>
|
|
92
|
+
</div>
|
|
93
|
+
</aside>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Input } from '@/components/ui/input'
|
|
5
|
+
import { PostCard } from './PostCard'
|
|
6
|
+
|
|
7
|
+
export interface BlogPost {
|
|
8
|
+
title: string
|
|
9
|
+
description: string
|
|
10
|
+
slug: string
|
|
11
|
+
date: Date
|
|
12
|
+
category?: string
|
|
13
|
+
image?: string
|
|
14
|
+
tags?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BlogSearchProps {
|
|
18
|
+
posts: BlogPost[]
|
|
19
|
+
placeholder?: string
|
|
20
|
+
gridColumns?: '2' | '3'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function BlogSearch({
|
|
24
|
+
posts,
|
|
25
|
+
placeholder = 'Search posts…',
|
|
26
|
+
gridColumns = '3',
|
|
27
|
+
}: BlogSearchProps) {
|
|
28
|
+
const [query, setQuery] = useState('')
|
|
29
|
+
|
|
30
|
+
const filtered = query.trim()
|
|
31
|
+
? posts.filter((post) => {
|
|
32
|
+
const q = query.toLowerCase()
|
|
33
|
+
return (
|
|
34
|
+
post.title.toLowerCase().includes(q) ||
|
|
35
|
+
post.description.toLowerCase().includes(q)
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
: posts
|
|
39
|
+
|
|
40
|
+
const gridClass =
|
|
41
|
+
gridColumns === '2'
|
|
42
|
+
? 'grid grid-cols-1 sm:grid-cols-2 gap-6'
|
|
43
|
+
: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6'
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="space-y-8">
|
|
47
|
+
<div className="flex justify-center">
|
|
48
|
+
<Input
|
|
49
|
+
type="search"
|
|
50
|
+
value={query}
|
|
51
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
52
|
+
placeholder={placeholder}
|
|
53
|
+
className="max-w-md"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{filtered.length === 0 ? (
|
|
58
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
59
|
+
<p className="text-muted-foreground text-sm">No posts found for “{query}”.</p>
|
|
60
|
+
</div>
|
|
61
|
+
) : (
|
|
62
|
+
<div className={gridClass}>
|
|
63
|
+
{filtered.map((post) => (
|
|
64
|
+
<PostCard key={post.slug} {...post} />
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
package/src/PostCard.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Calendar, Clock } from 'lucide-react'
|
|
2
|
+
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
|
3
|
+
import { Badge } from '@/components/ui/badge'
|
|
4
|
+
|
|
5
|
+
export interface PostCardProps {
|
|
6
|
+
title: string
|
|
7
|
+
description: string
|
|
8
|
+
slug: string
|
|
9
|
+
date: Date
|
|
10
|
+
category?: string
|
|
11
|
+
readingTime?: string
|
|
12
|
+
image?: string
|
|
13
|
+
tags?: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatDate(date: Date): string {
|
|
17
|
+
return date.toLocaleDateString('en-US', {
|
|
18
|
+
year: 'numeric',
|
|
19
|
+
month: 'short',
|
|
20
|
+
day: 'numeric',
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function PostCard({
|
|
25
|
+
title,
|
|
26
|
+
description,
|
|
27
|
+
slug,
|
|
28
|
+
date,
|
|
29
|
+
category,
|
|
30
|
+
readingTime,
|
|
31
|
+
image,
|
|
32
|
+
tags,
|
|
33
|
+
}: PostCardProps) {
|
|
34
|
+
return (
|
|
35
|
+
<a href={`/blog/${slug}`} className="block group focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg">
|
|
36
|
+
<Card className="h-full overflow-hidden transition-shadow duration-200 group-hover:shadow-md">
|
|
37
|
+
{image && (
|
|
38
|
+
<div className="aspect-video overflow-hidden">
|
|
39
|
+
<img
|
|
40
|
+
src={image}
|
|
41
|
+
alt={title}
|
|
42
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
<CardHeader className="pb-2">
|
|
47
|
+
{category && (
|
|
48
|
+
<Badge variant="secondary" className="w-fit mb-2 text-xs">
|
|
49
|
+
{category}
|
|
50
|
+
</Badge>
|
|
51
|
+
)}
|
|
52
|
+
<h2 className="text-lg font-semibold leading-snug group-hover:text-primary transition-colors line-clamp-2">
|
|
53
|
+
{title}
|
|
54
|
+
</h2>
|
|
55
|
+
</CardHeader>
|
|
56
|
+
<CardContent className="space-y-3">
|
|
57
|
+
<p className="text-sm text-muted-foreground line-clamp-2">{description}</p>
|
|
58
|
+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
59
|
+
<span className="flex items-center gap-1">
|
|
60
|
+
<Calendar className="w-3.5 h-3.5" />
|
|
61
|
+
{formatDate(date)}
|
|
62
|
+
</span>
|
|
63
|
+
{readingTime && (
|
|
64
|
+
<span className="flex items-center gap-1">
|
|
65
|
+
<Clock className="w-3.5 h-3.5" />
|
|
66
|
+
{readingTime}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
{tags && tags.length > 0 && (
|
|
71
|
+
<div className="flex flex-wrap gap-1 pt-1">
|
|
72
|
+
{tags.slice(0, 3).map((tag) => (
|
|
73
|
+
<Badge key={tag} variant="outline" className="text-xs px-1.5 py-0">
|
|
74
|
+
{tag}
|
|
75
|
+
</Badge>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
</a>
|
|
82
|
+
)
|
|
83
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro'
|
|
2
|
+
import type { OnePluginFactory } from '@oneie/frontend'
|
|
3
|
+
|
|
4
|
+
export interface OneBlogConfig {
|
|
5
|
+
/** Directory where blog posts live. Default: 'src/content/blog' */
|
|
6
|
+
postsDir?: string
|
|
7
|
+
/** Emit an RSS feed at /rss.xml. Default: true */
|
|
8
|
+
rss?: boolean
|
|
9
|
+
/** Inject /blog and /blog/[slug] routes automatically. Default: true */
|
|
10
|
+
injectRoutes?: boolean
|
|
11
|
+
/** Posts per page for the index grid. Default: 12 */
|
|
12
|
+
postsPerPage?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaults: Required<OneBlogConfig> = {
|
|
16
|
+
postsDir: 'src/content/blog',
|
|
17
|
+
rss: true,
|
|
18
|
+
injectRoutes: true,
|
|
19
|
+
postsPerPage: 12,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const blog: OnePluginFactory<OneBlogConfig> = (config = {}) => {
|
|
23
|
+
const resolved: Required<OneBlogConfig> = { ...defaults, ...config }
|
|
24
|
+
|
|
25
|
+
const integration = (_cfg: OneBlogConfig): AstroIntegration => ({
|
|
26
|
+
name: '@oneie/plugin-blog',
|
|
27
|
+
hooks: {
|
|
28
|
+
'astro:config:setup': ({ injectRoute }) => {
|
|
29
|
+
if (resolved.injectRoutes) {
|
|
30
|
+
injectRoute({
|
|
31
|
+
pattern: '/blog',
|
|
32
|
+
entrypoint: '@oneie/plugin-blog/BlogIndex.astro',
|
|
33
|
+
})
|
|
34
|
+
injectRoute({
|
|
35
|
+
pattern: '/blog/[slug]',
|
|
36
|
+
entrypoint: '@oneie/plugin-blog/BlogPost.astro',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name: 'plugin-blog',
|
|
45
|
+
tier: 'free',
|
|
46
|
+
config: undefined,
|
|
47
|
+
integration,
|
|
48
|
+
entitlement: undefined,
|
|
49
|
+
serves: undefined,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type { OneBlogConfig as BlogConfig }
|