@fayz-ai/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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/AuthorAvatar.d.ts +7 -0
  3. package/dist/components/AuthorAvatar.d.ts.map +1 -0
  4. package/dist/components/BlogList.d.ts +6 -0
  5. package/dist/components/BlogList.d.ts.map +1 -0
  6. package/dist/components/PostCard.d.ts +10 -0
  7. package/dist/components/PostCard.d.ts.map +1 -0
  8. package/dist/components/PostDetail.d.ts +7 -0
  9. package/dist/components/PostDetail.d.ts.map +1 -0
  10. package/dist/config.d.ts +38 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/context.d.ts +14 -0
  13. package/dist/context.d.ts.map +1 -0
  14. package/dist/data/index.d.ts +5 -0
  15. package/dist/data/index.d.ts.map +1 -0
  16. package/dist/data/mock.d.ts +18 -0
  17. package/dist/data/mock.d.ts.map +1 -0
  18. package/dist/data/supabase.d.ts +3 -0
  19. package/dist/data/supabase.d.ts.map +1 -0
  20. package/dist/data/types.d.ts +10 -0
  21. package/dist/data/types.d.ts.map +1 -0
  22. package/dist/hooks/index.d.ts +5 -0
  23. package/dist/hooks/index.d.ts.map +1 -0
  24. package/dist/hooks/useBlogPosts.d.ts +12 -0
  25. package/dist/hooks/useBlogPosts.d.ts.map +1 -0
  26. package/dist/hooks/usePost.d.ts +9 -0
  27. package/dist/hooks/usePost.d.ts.map +1 -0
  28. package/dist/index.cjs +372 -0
  29. package/dist/index.cjs.map +1 -0
  30. package/dist/index.d.ts +29 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +360 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/seo.d.ts +16 -0
  35. package/dist/seo.d.ts.map +1 -0
  36. package/dist/types.d.ts +38 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/package.json +51 -0
  39. package/src/components/AuthorAvatar.tsx +20 -0
  40. package/src/components/BlogList.tsx +54 -0
  41. package/src/components/PostCard.tsx +44 -0
  42. package/src/components/PostDetail.tsx +108 -0
  43. package/src/config.ts +50 -0
  44. package/src/context.tsx +23 -0
  45. package/src/data/index.ts +4 -0
  46. package/src/data/mock.ts +71 -0
  47. package/src/data/supabase.ts +28 -0
  48. package/src/data/types.ts +10 -0
  49. package/src/hooks/index.ts +4 -0
  50. package/src/hooks/useBlogPosts.ts +47 -0
  51. package/src/hooks/usePost.ts +46 -0
  52. package/src/index.ts +80 -0
  53. package/src/seo.ts +63 -0
  54. package/src/types.ts +45 -0
@@ -0,0 +1,38 @@
1
+ /** Medium-inspired author byline shown on cards and article pages. */
2
+ export interface BlogAuthor {
3
+ name: string;
4
+ /** Avatar image URL; a name-initial is shown as fallback. */
5
+ avatarUrl?: string;
6
+ /** Short role/handle under the name (e.g. "Cirurgião-Dentista"). */
7
+ role?: string;
8
+ /** One-line bio for the "about the author" block on the article page. */
9
+ bio?: string;
10
+ }
11
+ export interface BlogPost {
12
+ /** URL-safe identifier used for the /blog/:slug detail route. */
13
+ slug: string;
14
+ title: string;
15
+ /** Short summary shown on cards / list. */
16
+ excerpt: string;
17
+ /** Full article body. Plain text or lightweight markdown (paragraphs split on blank lines). */
18
+ body: string;
19
+ /** Category / topic label (e.g. "Periodontia"). */
20
+ tag: string;
21
+ /** Human-readable reading time (e.g. "5 min"). */
22
+ readTime: string;
23
+ /** Human-readable publish date (e.g. "Janeiro 2025"). */
24
+ date: string;
25
+ /** Machine date (ISO 8601) for SEO/JSON-LD datePublished. Optional. */
26
+ publishedAt?: string;
27
+ /** Cover image URL. */
28
+ image: string;
29
+ /** Byline author. Filled from the plugin's defaultAuthor when a post omits it. */
30
+ author: BlogAuthor;
31
+ }
32
+ export interface BlogListQuery {
33
+ /** Cap the number of posts returned (e.g. the home teaser shows 3). */
34
+ limit?: number;
35
+ /** Filter by tag. */
36
+ tag?: string;
37
+ }
38
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAMA,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,QAAQ;IACvB,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAA;IACf,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAA;IACZ,mDAAmD;IACnD,GAAG,EAAE,MAAM,CAAA;IACX,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAA;IAChB,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,uBAAuB;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,kFAAkF;IAClF,MAAM,EAAE,UAAU,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qBAAqB;IACrB,GAAG,CAAC,EAAE,MAAM,CAAA;CACb"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@fayz-ai/plugin-blog",
3
+ "version": "0.1.0",
4
+ "description": "[experimental] Fayz SDK — blog / articles plugin (website surface)",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "peerDependencies": {
22
+ "react": "^18.0.0 || ^19.0.0",
23
+ "react-dom": "^18.0.0 || ^19.0.0",
24
+ "react-router-dom": "^6.0.0 || ^7.0.0"
25
+ },
26
+ "dependencies": {
27
+ "lucide-react": "^0.400.0",
28
+ "@fayz-ai/core": "^0.7.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^18.3.0",
32
+ "react": "^18.3.0",
33
+ "react-dom": "^18.3.0",
34
+ "react-router-dom": "^6.30.1",
35
+ "tsup": "^8.2.0",
36
+ "typescript": "^5.5.0",
37
+ "@types/react-dom": "^18.3.0"
38
+ },
39
+ "license": "MIT",
40
+ "keywords": [
41
+ "fayz",
42
+ "fayz-plugin",
43
+ "fayz-sdk"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
47
+ "dev": "tsup --watch",
48
+ "typecheck": "tsc --noEmit",
49
+ "clean": "rm -rf dist"
50
+ }
51
+ }
@@ -0,0 +1,20 @@
1
+ import type { BlogAuthor } from '../types'
2
+
3
+ /** Round author avatar with a name-initial fallback (token-only). */
4
+ export function AuthorAvatar({ author, className = 'h-8 w-8 text-xs' }: { author: BlogAuthor; className?: string }) {
5
+ if (author.avatarUrl) {
6
+ return (
7
+ <img
8
+ src={author.avatarUrl}
9
+ alt={author.name}
10
+ loading="lazy"
11
+ className={`shrink-0 rounded-full object-cover ${className}`}
12
+ />
13
+ )
14
+ }
15
+ return (
16
+ <span className={`flex shrink-0 items-center justify-center rounded-full bg-accent font-semibold text-primary ${className}`}>
17
+ {author.name.charAt(0).toUpperCase()}
18
+ </span>
19
+ )
20
+ }
@@ -0,0 +1,54 @@
1
+ import { useBlogPosts } from '../hooks/useBlogPosts'
2
+ import { useBlogContext } from '../context'
3
+ import { PostCard } from './PostCard'
4
+ import { useSeo, blogJsonLd } from '../seo'
5
+
6
+ /**
7
+ * Full /blog list screen. Rendered inside the host's chrome (Header/Footer),
8
+ * so it only owns the section body — token-only for theme inheritance.
9
+ */
10
+ export function BlogList() {
11
+ const { config } = useBlogContext()
12
+ const { posts, loading, error } = useBlogPosts()
13
+
14
+ const listTitle = config.labels.listSubtitle || config.labels.listTitle
15
+ useSeo({
16
+ title: `${listTitle}${config.siteName ? ` · ${config.siteName}` : ''}`,
17
+ description: config.labels.listSubtitle || `Artigos do blog${config.siteName ? ` da ${config.siteName}` : ''}.`,
18
+ canonical: `${typeof window !== 'undefined' ? window.location.origin : ''}${config.basePath}`,
19
+ type: 'website',
20
+ siteName: config.siteName,
21
+ jsonLd: posts.length ? blogJsonLd(posts, { basePath: config.basePath, siteName: config.siteName, title: listTitle }) : undefined,
22
+ })
23
+
24
+ return (
25
+ <section className="py-20 bg-background">
26
+ <div className="container mx-auto px-6">
27
+ <div className="mb-12 max-w-2xl">
28
+ <span className="inline-block rounded-full bg-accent px-4 py-1 text-xs font-semibold uppercase tracking-widest text-primary mb-4">
29
+ {config.labels.listTitle}
30
+ </span>
31
+ {config.labels.listSubtitle ? (
32
+ <h1 className="font-heading text-4xl md:text-5xl font-bold text-foreground">
33
+ {config.labels.listSubtitle}
34
+ </h1>
35
+ ) : null}
36
+ </div>
37
+
38
+ {loading ? (
39
+ <p className="text-muted-foreground">Carregando artigos…</p>
40
+ ) : error ? (
41
+ <p className="text-muted-foreground">Não foi possível carregar os artigos.</p>
42
+ ) : posts.length === 0 ? (
43
+ <p className="text-muted-foreground">Nenhum artigo publicado ainda.</p>
44
+ ) : (
45
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
46
+ {posts.map((post) => (
47
+ <PostCard key={post.slug} post={post} basePath={config.basePath} />
48
+ ))}
49
+ </div>
50
+ )}
51
+ </div>
52
+ </section>
53
+ )
54
+ }
@@ -0,0 +1,44 @@
1
+ import { Link } from 'react-router-dom'
2
+ import { Clock } from 'lucide-react'
3
+ import type { BlogPost } from '../types'
4
+ import { AuthorAvatar } from './AuthorAvatar'
5
+
6
+ /**
7
+ * Default post card — token-only so it inherits the host theme. Optional: a host
8
+ * with bespoke card markup can ignore this and map `useBlogPosts()` itself.
9
+ */
10
+ export function PostCard({ post, basePath = '/blog' }: { post: BlogPost; basePath?: string }) {
11
+ return (
12
+ <Link
13
+ to={`${basePath}/${post.slug}`}
14
+ className="group flex flex-col rounded-2xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
15
+ >
16
+ <div className="overflow-hidden h-48">
17
+ <img
18
+ src={post.image}
19
+ alt={post.title}
20
+ loading="lazy"
21
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
22
+ />
23
+ </div>
24
+ <div className="flex flex-col flex-1 p-6">
25
+ <span className="inline-block rounded-full bg-accent px-3 py-0.5 text-xs font-semibold text-primary mb-3 w-fit">
26
+ {post.tag}
27
+ </span>
28
+ <h3 className="font-heading text-lg font-semibold text-foreground leading-snug mb-3 group-hover:text-primary transition-colors duration-200">
29
+ {post.title}
30
+ </h3>
31
+ <p className="text-muted-foreground text-sm leading-relaxed flex-1 mb-4">{post.excerpt}</p>
32
+ <div className="flex items-center gap-2.5 pt-4 border-t border-border">
33
+ <AuthorAvatar author={post.author} className="h-7 w-7 text-xs" />
34
+ <div className="min-w-0">
35
+ <p className="truncate text-xs font-semibold text-foreground">{post.author.name}</p>
36
+ <p className="flex items-center gap-1 text-[11px] text-muted-foreground">
37
+ {post.date}<span aria-hidden>·</span><Clock className="h-3 w-3" />{post.readTime}
38
+ </p>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </Link>
43
+ )
44
+ }
@@ -0,0 +1,108 @@
1
+ import { Link, useParams } from 'react-router-dom'
2
+ import { Clock } from 'lucide-react'
3
+ import { usePost } from '../hooks/usePost'
4
+ import { useBlogContext } from '../context'
5
+ import { AuthorAvatar } from './AuthorAvatar'
6
+ import { useSeo, articleJsonLd, postUrl } from '../seo'
7
+
8
+ /**
9
+ * /blog/:slug detail screen. Reads the slug from the router and renders the
10
+ * article inside the host chrome. Token-only for theme inheritance. Sets rich
11
+ * SEO/JSON-LD per article (BlogPosting + BreadcrumbList).
12
+ */
13
+ export function PostDetail() {
14
+ const { config } = useBlogContext()
15
+ const { slug } = useParams<{ slug: string }>()
16
+ const { post, loading, error } = usePost(slug)
17
+
18
+ useSeo(
19
+ post
20
+ ? {
21
+ title: `${post.title}${config.siteName ? ` · ${config.siteName}` : ''}`,
22
+ description: post.excerpt,
23
+ canonical: postUrl(config.basePath, post.slug),
24
+ image: post.image,
25
+ type: 'article',
26
+ siteName: config.siteName,
27
+ jsonLd: articleJsonLd(post, { basePath: config.basePath, siteName: config.siteName }),
28
+ }
29
+ : { title: config.siteName || 'Blog' },
30
+ )
31
+
32
+ const paragraphs = post?.body ? post.body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean) : []
33
+
34
+ return (
35
+ <article className="py-16 bg-background">
36
+ <div className="container mx-auto px-6 max-w-3xl">
37
+ <Link to={config.basePath} className="text-sm font-medium text-primary hover:underline">
38
+ {config.labels.backToList}
39
+ </Link>
40
+
41
+ {loading ? (
42
+ <p className="mt-10 text-muted-foreground">Carregando…</p>
43
+ ) : error || !post ? (
44
+ <div className="mt-10">
45
+ <h1 className="font-heading text-3xl font-bold text-foreground">Artigo não encontrado</h1>
46
+ <p className="mt-2 text-muted-foreground">Este artigo pode ter sido movido ou removido.</p>
47
+ </div>
48
+ ) : (
49
+ <>
50
+ <h1 className="mt-8 font-heading text-4xl md:text-5xl font-bold text-foreground leading-tight">
51
+ {post.title}
52
+ </h1>
53
+
54
+ {/* Medium-style byline */}
55
+ <div className="mt-6 flex items-center gap-3">
56
+ <AuthorAvatar author={post.author} className="h-11 w-11 text-base" />
57
+ <div>
58
+ <p className="font-semibold text-foreground">{post.author.name}</p>
59
+ <p className="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
60
+ {post.author.role ? <span>{post.author.role}<span aria-hidden> · </span></span> : null}
61
+ <time dateTime={post.publishedAt}>{post.date}</time>
62
+ <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{post.readTime} de leitura</span>
63
+ </p>
64
+ </div>
65
+ </div>
66
+
67
+ <div className="mt-8 overflow-hidden rounded-2xl">
68
+ <img src={post.image} alt={post.title} className="w-full object-cover" loading="lazy" />
69
+ </div>
70
+
71
+ <div className="mt-8 space-y-5">
72
+ <p className="text-lg text-foreground/90 leading-relaxed font-medium">{post.excerpt}</p>
73
+ {paragraphs.map((para, i) => (
74
+ <p key={i} className="text-muted-foreground leading-relaxed">
75
+ {para}
76
+ </p>
77
+ ))}
78
+ </div>
79
+
80
+ {/* Category tag — at the bottom, Medium-style */}
81
+ <div className="mt-10 flex flex-wrap items-center gap-2 border-t border-border pt-6">
82
+ <span className="text-sm text-muted-foreground">Categoria:</span>
83
+ <Link
84
+ to={config.basePath}
85
+ className="rounded-full bg-accent px-3 py-1 text-xs font-semibold text-primary transition-colors hover:bg-accent/70"
86
+ >
87
+ {post.tag}
88
+ </Link>
89
+ </div>
90
+
91
+ {/* About the author */}
92
+ {post.author.bio ? (
93
+ <div className="mt-8 flex items-start gap-4 rounded-2xl border border-border bg-muted/40 p-6">
94
+ <AuthorAvatar author={post.author} className="h-14 w-14 text-lg" />
95
+ <div>
96
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Escrito por</p>
97
+ <p className="font-heading text-lg font-bold text-foreground">{post.author.name}</p>
98
+ {post.author.role ? <p className="text-sm text-primary">{post.author.role}</p> : null}
99
+ <p className="mt-2 text-sm text-muted-foreground leading-relaxed">{post.author.bio}</p>
100
+ </div>
101
+ </div>
102
+ ) : null}
103
+ </>
104
+ )}
105
+ </div>
106
+ </article>
107
+ )
108
+ }
package/src/config.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { PluginScope, VerticalId } from '@fayz-ai/core'
2
+ import type { BlogDataProvider } from './data/types'
3
+ import type { BlogSeed } from './data/mock'
4
+ import type { BlogAuthor } from './types'
5
+
6
+ export interface BlogPluginOptions {
7
+ /** Base path the plugin mounts its public routes under. Default '/blog'. */
8
+ basePath?: string
9
+ /** Seed posts for the mock provider (used until a real backend is wired). */
10
+ seed?: BlogSeed
11
+ /** Default byline author, applied to any post that doesn't specify its own. */
12
+ defaultAuthor?: BlogAuthor
13
+ /** Publisher/brand name — used for SEO (og:site_name, JSON-LD publisher). */
14
+ siteName?: string
15
+ /** Inject a custom provider (e.g. a CMS). Overrides the safe mock/Supabase resolver. */
16
+ dataProvider?: BlogDataProvider
17
+ /** Section eyebrow / labels for the default list screen. */
18
+ labels?: {
19
+ /** Title shown at the top of the /blog list screen. */
20
+ listTitle?: string
21
+ /** Subtitle under the list title. */
22
+ listSubtitle?: string
23
+ /** "Back to blog" link label on the detail screen. */
24
+ backToList?: string
25
+ }
26
+ scope?: PluginScope
27
+ verticalId?: VerticalId
28
+ }
29
+
30
+ export interface ResolvedBlogConfig {
31
+ basePath: string
32
+ siteName: string
33
+ labels: {
34
+ listTitle: string
35
+ listSubtitle: string
36
+ backToList: string
37
+ }
38
+ }
39
+
40
+ export function resolveConfig(options?: BlogPluginOptions): ResolvedBlogConfig {
41
+ return {
42
+ basePath: options?.basePath ?? '/blog',
43
+ siteName: options?.siteName ?? '',
44
+ labels: {
45
+ listTitle: options?.labels?.listTitle ?? 'Blog',
46
+ listSubtitle: options?.labels?.listSubtitle ?? '',
47
+ backToList: options?.labels?.backToList ?? '← Voltar ao blog',
48
+ },
49
+ }
50
+ }
@@ -0,0 +1,23 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { BlogDataProvider } from './data/types'
3
+ import type { ResolvedBlogConfig } from './config'
4
+
5
+ export interface BlogContextValue {
6
+ provider: BlogDataProvider
7
+ config: ResolvedBlogConfig
8
+ }
9
+
10
+ const BlogContext = createContext<BlogContextValue | null>(null)
11
+
12
+ export function BlogProvider({ value, children }: { value: BlogContextValue; children: ReactNode }) {
13
+ return <BlogContext.Provider value={value}>{children}</BlogContext.Provider>
14
+ }
15
+
16
+ /** Read the blog provider + config. Throws if used outside a BlogProvider. */
17
+ export function useBlogContext(): BlogContextValue {
18
+ const ctx = useContext(BlogContext)
19
+ if (!ctx) {
20
+ throw new Error('[plugin-blog] useBlogContext must be used within <BlogProvider>. Mount the plugin Provider at the app root.')
21
+ }
22
+ return ctx
23
+ }
@@ -0,0 +1,4 @@
1
+ export type { BlogDataProvider } from './types'
2
+ export { createMockBlogProvider } from './mock'
3
+ export type { BlogSeed, BlogSeedPost, MockBlogProviderOptions } from './mock'
4
+ export { createSupabaseBlogProvider } from './supabase'
@@ -0,0 +1,71 @@
1
+ import type { BlogDataProvider } from './types'
2
+ import type { BlogPost, BlogListQuery, BlogAuthor } from '../types'
3
+
4
+ /** Slugify a title when a seed post omits an explicit slug. */
5
+ function slugify(input: string): string {
6
+ return input
7
+ .normalize('NFD')
8
+ // Drop combining accent marks (and any other non-ASCII) so "saúde" -> "saude".
9
+ .replace(/[^\x00-\x7f]/g, '')
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/(^-|-$)/g, '')
13
+ .slice(0, 80)
14
+ }
15
+
16
+ /** Seed post — same shape as BlogPost but slug/body/author may be derived. */
17
+ export type BlogSeedPost = Omit<BlogPost, 'slug' | 'body' | 'author'> & {
18
+ slug?: string
19
+ body?: string
20
+ author?: BlogAuthor
21
+ }
22
+
23
+ export interface BlogSeed {
24
+ posts: BlogSeedPost[]
25
+ }
26
+
27
+ export interface MockBlogProviderOptions {
28
+ seed?: BlogSeed
29
+ /** Fallback byline used for posts without their own author. */
30
+ defaultAuthor?: BlogAuthor
31
+ }
32
+
33
+ const FALLBACK_AUTHOR: BlogAuthor = { name: 'Equipe' }
34
+
35
+ function normalize(post: BlogSeedPost, defaultAuthor: BlogAuthor): BlogPost {
36
+ return {
37
+ ...post,
38
+ slug: post.slug ?? slugify(post.title),
39
+ body: post.body ?? post.excerpt,
40
+ author: post.author ?? defaultAuthor,
41
+ }
42
+ }
43
+
44
+ /** A built-in fallback post so an unseeded plugin still renders something. */
45
+ const FALLBACK: BlogSeedPost[] = [
46
+ {
47
+ title: 'Bem-vindo ao blog',
48
+ excerpt: 'Este é um artigo de exemplo servido pelo plugin de blog do Fayz.',
49
+ tag: 'Novidades',
50
+ readTime: '2 min',
51
+ date: 'Hoje',
52
+ image: 'https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=800&q=80',
53
+ },
54
+ ]
55
+
56
+ export function createMockBlogProvider(options?: MockBlogProviderOptions): BlogDataProvider {
57
+ const defaultAuthor = options?.defaultAuthor ?? FALLBACK_AUTHOR
58
+ const posts: BlogPost[] = (options?.seed?.posts ?? FALLBACK).map((p) => normalize(p, defaultAuthor))
59
+
60
+ return {
61
+ async listPosts(query?: BlogListQuery): Promise<BlogPost[]> {
62
+ let result = posts
63
+ if (query?.tag) result = result.filter((p) => p.tag === query.tag)
64
+ if (query?.limit != null) result = result.slice(0, query.limit)
65
+ return result
66
+ },
67
+ async getPost(slug: string): Promise<BlogPost | null> {
68
+ return posts.find((p) => p.slug === slug) ?? null
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,28 @@
1
+ import type { BlogDataProvider } from './types'
2
+ import type { BlogPost, BlogListQuery } from '../types'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Supabase-backed blog provider — STUB.
6
+ //
7
+ // Deferred (Phase 2): read from a `blog_posts` table (tenant-scoped, RLS) with
8
+ // columns mirroring BlogPost (slug, title, excerpt, body, tag, read_time, date,
9
+ // image, author). Wiring this up is a pure provider swap — no hook/component
10
+ // change — because everything downstream depends only on BlogDataProvider.
11
+ //
12
+ // Until then this throws, which makes createSafeDataProvider fall back to the
13
+ // mock provider whenever no Supabase client is configured. If a client IS
14
+ // configured but this is still a stub, surface a clear error.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export function createSupabaseBlogProvider(): BlogDataProvider {
18
+ const notImplemented = (): never => {
19
+ throw new Error(
20
+ '[plugin-blog] Supabase provider not implemented yet — deferred to Phase 2. ' +
21
+ 'Run on the mock/seed provider (no Supabase client configured) for now.',
22
+ )
23
+ }
24
+ return {
25
+ listPosts: (_query?: BlogListQuery): Promise<BlogPost[]> => notImplemented(),
26
+ getPost: (_slug: string): Promise<BlogPost | null> => notImplemented(),
27
+ }
28
+ }
@@ -0,0 +1,10 @@
1
+ import type { BlogPost, BlogListQuery } from '../types'
2
+
3
+ /**
4
+ * The data seam for the blog plugin. A mock/seed implementation powers the POC;
5
+ * a Supabase (or CMS) implementation swaps in later with no component change.
6
+ */
7
+ export interface BlogDataProvider {
8
+ listPosts(query?: BlogListQuery): Promise<BlogPost[]>
9
+ getPost(slug: string): Promise<BlogPost | null>
10
+ }
@@ -0,0 +1,4 @@
1
+ export { useBlogPosts } from './useBlogPosts'
2
+ export type { UseBlogPostsResult } from './useBlogPosts'
3
+ export { usePost } from './usePost'
4
+ export type { UsePostResult } from './usePost'
@@ -0,0 +1,47 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useBlogContext } from '../context'
3
+ import type { BlogPost, BlogListQuery } from '../types'
4
+
5
+ export interface UseBlogPostsResult {
6
+ posts: BlogPost[]
7
+ loading: boolean
8
+ error: Error | null
9
+ }
10
+
11
+ /**
12
+ * Fetch the post list from the active blog provider. The host maps `posts` into
13
+ * its own card markup; the plugin never dictates presentation.
14
+ */
15
+ export function useBlogPosts(query?: BlogListQuery): UseBlogPostsResult {
16
+ const { provider } = useBlogContext()
17
+ const [posts, setPosts] = useState<BlogPost[]>([])
18
+ const [loading, setLoading] = useState(true)
19
+ const [error, setError] = useState<Error | null>(null)
20
+
21
+ const limit = query?.limit
22
+ const tag = query?.tag
23
+
24
+ useEffect(() => {
25
+ let active = true
26
+ setLoading(true)
27
+ provider
28
+ .listPosts({ limit, tag })
29
+ .then((result) => {
30
+ if (!active) return
31
+ setPosts(result)
32
+ setError(null)
33
+ })
34
+ .catch((err) => {
35
+ if (!active) return
36
+ setError(err instanceof Error ? err : new Error(String(err)))
37
+ })
38
+ .finally(() => {
39
+ if (active) setLoading(false)
40
+ })
41
+ return () => {
42
+ active = false
43
+ }
44
+ }, [provider, limit, tag])
45
+
46
+ return { posts, loading, error }
47
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useBlogContext } from '../context'
3
+ import type { BlogPost } from '../types'
4
+
5
+ export interface UsePostResult {
6
+ post: BlogPost | null
7
+ loading: boolean
8
+ error: Error | null
9
+ }
10
+
11
+ /** Fetch a single post by slug from the active blog provider. */
12
+ export function usePost(slug: string | undefined): UsePostResult {
13
+ const { provider } = useBlogContext()
14
+ const [post, setPost] = useState<BlogPost | null>(null)
15
+ const [loading, setLoading] = useState(true)
16
+ const [error, setError] = useState<Error | null>(null)
17
+
18
+ useEffect(() => {
19
+ let active = true
20
+ if (!slug) {
21
+ setPost(null)
22
+ setLoading(false)
23
+ return
24
+ }
25
+ setLoading(true)
26
+ provider
27
+ .getPost(slug)
28
+ .then((result) => {
29
+ if (!active) return
30
+ setPost(result)
31
+ setError(null)
32
+ })
33
+ .catch((err) => {
34
+ if (!active) return
35
+ setError(err instanceof Error ? err : new Error(String(err)))
36
+ })
37
+ .finally(() => {
38
+ if (active) setLoading(false)
39
+ })
40
+ return () => {
41
+ active = false
42
+ }
43
+ }, [provider, slug])
44
+
45
+ return { post, loading, error }
46
+ }