@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 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 &ldquo;{query}&rdquo;.</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
+ }
@@ -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 }
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
+ }