@fayz-ai/plugin-blog 0.9.0 → 0.11.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.
@@ -1,95 +0,0 @@
1
- import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
- import type { BlogDataProvider } from './types'
3
- import type { BlogPost, BlogListQuery, BlogAuthor } from '../types'
4
-
5
- // ---------------------------------------------------------------------------
6
- // Supabase-backed blog provider (public/website read side).
7
- //
8
- // Reads PUBLISHED posts from the anon-safe view public.v_public_blog_posts
9
- // (see src/migrations/001_blog.sql §4), scoped to a tenant. The backoffice
10
- // writes to plg_blog_posts directly via the generic CRUD provider; here we only
11
- // read the published, column-whitelisted view — safe under the publishable key.
12
- //
13
- // The tenant is taken from the configured tenantId (anon site) or, failing that,
14
- // the active session tenant. Without a client or a tenant we return nothing, and
15
- // createSafeDataProvider falls back to the mock/seed provider.
16
- // ---------------------------------------------------------------------------
17
-
18
- const PUBLIC_VIEW = 'v_public_blog_posts'
19
-
20
- export interface SupabaseBlogProviderOptions {
21
- /** Tenant whose published posts to read (anon website). Defaults to the active session tenant. */
22
- tenantId?: string
23
- }
24
-
25
- function getClient() {
26
- const supabase = getSupabaseClientOptional() as {
27
- from: (t: string) => any
28
- } | null
29
- if (!supabase) throw new Error('[plugin-blog] Supabase client not initialized')
30
- return supabase
31
- }
32
-
33
- /** Human month-year label (pt-BR) derived from an ISO date, e.g. "Janeiro 2025". */
34
- function humanDate(iso?: string | null): string {
35
- if (!iso) return ''
36
- const d = new Date(iso)
37
- if (Number.isNaN(d.getTime())) return ''
38
- const label = d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
39
- return label.charAt(0).toUpperCase() + label.slice(1)
40
- }
41
-
42
- function toPost(row: Record<string, any>): BlogPost {
43
- const author: BlogAuthor = {
44
- name: row.author_name ?? 'Equipe',
45
- avatarUrl: row.author_avatar_url ?? undefined,
46
- role: row.author_role ?? undefined,
47
- bio: row.author_bio ?? undefined,
48
- }
49
- return {
50
- slug: row.slug,
51
- title: row.title,
52
- excerpt: row.excerpt ?? '',
53
- body: row.body ?? '',
54
- tag: row.tag ?? '',
55
- readTime: row.read_time ?? '',
56
- date: humanDate(row.published_at),
57
- publishedAt: row.published_at ?? undefined,
58
- image: row.cover_image ?? '',
59
- author,
60
- }
61
- }
62
-
63
- export function createSupabaseBlogProvider(options?: SupabaseBlogProviderOptions): BlogDataProvider {
64
- const resolveTenantId = (): string | undefined => options?.tenantId ?? getActiveTenantId() ?? undefined
65
-
66
- return {
67
- async listPosts(query?: BlogListQuery): Promise<BlogPost[]> {
68
- const tenantId = resolveTenantId()
69
- if (!tenantId) return []
70
- let q = getClient()
71
- .from(PUBLIC_VIEW)
72
- .select('*')
73
- .eq('tenant_id', tenantId)
74
- .order('published_at', { ascending: false, nullsFirst: false })
75
- if (query?.tag) q = q.eq('tag', query.tag)
76
- if (query?.limit != null) q = q.limit(query.limit)
77
- const { data, error } = await q
78
- if (error) throw error
79
- return (data ?? []).map(toPost)
80
- },
81
-
82
- async getPost(slug: string): Promise<BlogPost | null> {
83
- const tenantId = resolveTenantId()
84
- if (!tenantId) return null
85
- const { data, error } = await getClient()
86
- .from(PUBLIC_VIEW)
87
- .select('*')
88
- .eq('tenant_id', tenantId)
89
- .eq('slug', slug)
90
- .maybeSingle()
91
- if (error) throw error
92
- return data ? toPost(data) : null
93
- },
94
- }
95
- }
package/src/data/types.ts DELETED
@@ -1,10 +0,0 @@
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
- }
@@ -1,4 +0,0 @@
1
- export { useBlogPosts } from './useBlogPosts'
2
- export type { UseBlogPostsResult } from './useBlogPosts'
3
- export { usePost } from './usePost'
4
- export type { UsePostResult } from './usePost'
@@ -1,47 +0,0 @@
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
- }
@@ -1,46 +0,0 @@
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
- }
package/src/index.ts DELETED
@@ -1,92 +0,0 @@
1
- import { createElement, type FC, type ReactNode } from 'react'
2
- import type { PluginManifest } from '@fayz-ai/core'
3
- import { createSafeDataProvider } from '@fayz-ai/core'
4
- import { resolveConfig, type BlogPluginOptions } from './config'
5
- import { createMockBlogProvider } from './data/mock'
6
- import { createSupabaseBlogProvider } from './data/supabase'
7
- import { BlogProvider, type BlogContextValue } from './context'
8
- import { BlogList } from './components/BlogList'
9
- import { PostDetail } from './components/PostDetail'
10
- import { MIGRATIONS } from './migrations'
11
-
12
- // ---------------------------------------------------------------------------
13
- // @fayz-ai/plugin-blog — website-surface blog/articles plugin.
14
- //
15
- // First real consumer of scaffolds:['website']. Ships public routes (/blog,
16
- // /blog/:slug) + data hooks (useBlogPosts/usePost). A bespoke host site feeds
17
- // its own card markup from the hooks and mounts the routes inside its chrome.
18
- // See createBlogPlugin's return: a { manifest, Provider } bundle the host reads
19
- // uniformly (manifest.routes for mounting, Provider for the app-root context).
20
- // ---------------------------------------------------------------------------
21
-
22
- export interface BlogWebsitePlugin {
23
- /** Standard plugin manifest (routes + metadata). */
24
- manifest: PluginManifest
25
- /** App-root context provider, pre-bound to this plugin's resolved provider/config. */
26
- Provider: FC<{ children: ReactNode }>
27
- /** The resolved data provider (exposed for advanced host wiring/tests). */
28
- dataProvider: BlogContextValue['provider']
29
- }
30
-
31
- export function createBlogPlugin(options?: BlogPluginOptions): BlogWebsitePlugin {
32
- const config = resolveConfig(options)
33
- const provider =
34
- options?.dataProvider ??
35
- createSafeDataProvider(
36
- () => createSupabaseBlogProvider({ tenantId: options?.tenantId }),
37
- () => createMockBlogProvider({ seed: options?.seed, defaultAuthor: options?.defaultAuthor }),
38
- )
39
-
40
- const value: BlogContextValue = { provider, config }
41
- const Provider: FC<{ children: ReactNode }> = ({ children }) =>
42
- createElement(BlogProvider, { value, children })
43
- Provider.displayName = 'BlogPluginProvider'
44
-
45
- const manifest: PluginManifest = {
46
- id: 'blog',
47
- name: 'Blog',
48
- icon: 'Newspaper',
49
- version: '0.1.0',
50
- scope: options?.scope ?? 'universal',
51
- verticalId: options?.verticalId,
52
- scaffolds: ['website', 'landing_page'],
53
- defaultEnabled: true,
54
- dependencies: [],
55
- declaredFeatures: [{ id: 'blog', label: 'Blog', group: 'Attract' }],
56
- // Blog posts count against the plan cap. The create UI lives in the
57
- // marketing "Blog" backoffice, which writes to this plugin's posts table.
58
- declaredLimits: [{ key: 'blog_posts', label: 'Blog posts', table: 'plg_blog_posts' }],
59
- // Host owns website nav; no admin nav in M1.
60
- navigation: [],
61
- routes: [
62
- { path: config.basePath, component: BlogList, guard: 'public' },
63
- { path: `${config.basePath}/:slug`, component: PostDetail, guard: 'public' },
64
- ],
65
- widgets: [],
66
- // Owns the blog domain tables (categories + posts + anon-read view). The
67
- // marketing "Blog" backoffice writes to these; the website reads them.
68
- migrations: MIGRATIONS.map((m) => ({
69
- id: `blog-${m.id}`,
70
- version: '1.0.0',
71
- sql: m.sql,
72
- description: 'Blog content model (plg_blog_categories, plg_blog_posts) + anon-read view',
73
- })),
74
- }
75
-
76
- return { manifest, Provider, dataProvider: provider }
77
- }
78
-
79
- // Public API
80
- export type { BlogPluginOptions, ResolvedBlogConfig } from './config'
81
- export type { BlogPost, BlogListQuery, BlogAuthor, BlogCategory } from './types'
82
- export type { BlogDataProvider } from './data/types'
83
- export { createMockBlogProvider, createSupabaseBlogProvider } from './data'
84
- export type { BlogSeed, BlogSeedPost } from './data/mock'
85
- export { BlogProvider, useBlogContext } from './context'
86
- export type { BlogContextValue } from './context'
87
- export { useBlogPosts, usePost } from './hooks'
88
- export type { UseBlogPostsResult, UsePostResult } from './hooks'
89
- export { BlogList } from './components/BlogList'
90
- export { PostDetail } from './components/PostDetail'
91
- export { PostCard } from './components/PostCard'
92
- export { AuthorAvatar } from './components/AuthorAvatar'
@@ -1,103 +0,0 @@
1
- -- ============================================================================
2
- -- plugin-blog 001: blog content model + public (anon) read surface
3
- --
4
- -- Backs the blog backoffice (managed from the marketing plugin's "Blog" tab) and
5
- -- the public website surface (/blog, /blog/:slug). Prefix: plg_blog_.
6
- -- §1 plg_blog_categories — first-class category (name/slug/description)
7
- -- §2 plg_blog_posts — articles; category_id FK, author byline, status
8
- -- §3 RLS: authenticated tenant CRUD on both tables (the backoffice)
9
- -- §4 v_public_blog_posts — anon-readable view of PUBLISHED posts (the site)
10
- --
11
- -- Anon-access design mirrors plugin-agenda's public booking surface: the public
12
- -- website runs with the publishable key and no session, so canonical tenant RLS
13
- -- yields nothing. Anon read is granted ONLY through the column-whitelisted,
14
- -- published-only view (§4); the front provider filters it by tenant_id.
15
- -- Idempotent + safe to re-run.
16
- -- ============================================================================
17
-
18
- -- §1 — categories
19
- CREATE TABLE IF NOT EXISTS public.plg_blog_categories (
20
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
21
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
22
- name text NOT NULL,
23
- slug text NOT NULL,
24
- description text,
25
- created_at timestamptz NOT NULL DEFAULT now(),
26
- updated_at timestamptz NOT NULL DEFAULT now()
27
- );
28
- ALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;
29
- CREATE INDEX IF NOT EXISTS idx_plg_blog_categories_tenant ON public.plg_blog_categories(tenant_id);
30
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories(tenant_id, slug);
31
-
32
- -- §2 — posts
33
- CREATE TABLE IF NOT EXISTS public.plg_blog_posts (
34
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
35
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
36
- category_id uuid REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL,
37
- slug text NOT NULL,
38
- title text NOT NULL,
39
- excerpt text NOT NULL DEFAULT '',
40
- body text NOT NULL DEFAULT '',
41
- cover_image text,
42
- author_name text,
43
- author_role text,
44
- author_avatar_url text,
45
- author_bio text,
46
- status text NOT NULL DEFAULT 'draft'
47
- CHECK (status IN ('draft', 'published')),
48
- read_time text,
49
- published_at timestamptz,
50
- created_at timestamptz NOT NULL DEFAULT now(),
51
- updated_at timestamptz NOT NULL DEFAULT now()
52
- );
53
- ALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;
54
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_tenant ON public.plg_blog_posts(tenant_id);
55
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_category ON public.plg_blog_posts(category_id);
56
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_status ON public.plg_blog_posts(tenant_id, status, published_at DESC);
57
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts(tenant_id, slug);
58
-
59
- -- §3 — RLS: authenticated tenant CRUD (the backoffice writes here)
60
- DROP POLICY IF EXISTS plg_blog_categories_select ON public.plg_blog_categories;
61
- DROP POLICY IF EXISTS plg_blog_categories_insert ON public.plg_blog_categories;
62
- DROP POLICY IF EXISTS plg_blog_categories_update ON public.plg_blog_categories;
63
- DROP POLICY IF EXISTS plg_blog_categories_delete ON public.plg_blog_categories;
64
- CREATE POLICY plg_blog_categories_select ON public.plg_blog_categories FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
65
- CREATE POLICY plg_blog_categories_insert ON public.plg_blog_categories FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
66
- CREATE POLICY plg_blog_categories_update ON public.plg_blog_categories FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
67
- CREATE POLICY plg_blog_categories_delete ON public.plg_blog_categories FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
68
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_categories TO authenticated;
69
-
70
- DROP POLICY IF EXISTS plg_blog_posts_select ON public.plg_blog_posts;
71
- DROP POLICY IF EXISTS plg_blog_posts_insert ON public.plg_blog_posts;
72
- DROP POLICY IF EXISTS plg_blog_posts_update ON public.plg_blog_posts;
73
- DROP POLICY IF EXISTS plg_blog_posts_delete ON public.plg_blog_posts;
74
- CREATE POLICY plg_blog_posts_select ON public.plg_blog_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
75
- CREATE POLICY plg_blog_posts_insert ON public.plg_blog_posts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
76
- CREATE POLICY plg_blog_posts_update ON public.plg_blog_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
77
- CREATE POLICY plg_blog_posts_delete ON public.plg_blog_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
78
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_posts TO authenticated;
79
-
80
- -- §4 — anon-readable public catalog (owner-rights view = deliberate, whitelisted,
81
- -- PUBLISHED only). The front provider filters by tenant_id. Category name is
82
- -- surfaced as `tag` so the website's BlogPost shape stays unchanged.
83
- CREATE OR REPLACE VIEW public.v_public_blog_posts AS
84
- SELECT
85
- p.id,
86
- p.tenant_id,
87
- p.slug,
88
- p.title,
89
- p.excerpt,
90
- p.body,
91
- COALESCE(c.name, '') AS tag,
92
- p.cover_image,
93
- p.author_name,
94
- p.author_role,
95
- p.author_avatar_url,
96
- p.author_bio,
97
- p.read_time,
98
- p.published_at
99
- FROM public.plg_blog_posts p
100
- LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
101
- WHERE p.status = 'published';
102
-
103
- GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
@@ -1,112 +0,0 @@
1
- // AUTO-GENERATED from 001_blog.sql — regenerate with scripts/embed-migrations.mjs
2
- // SQL files are the source of truth; this inline copy lets the manifest declare
3
- // migrations as data. Do not edit by hand — run the embed script instead.
4
-
5
- export const MIGRATION_001_BLOG = `-- ============================================================================
6
- -- plugin-blog 001: blog content model + public (anon) read surface
7
- --
8
- -- Backs the blog backoffice (managed from the marketing plugin's "Blog" tab) and
9
- -- the public website surface (/blog, /blog/:slug). Prefix: plg_blog_.
10
- -- §1 plg_blog_categories — first-class category (name/slug/description)
11
- -- §2 plg_blog_posts — articles; category_id FK, author byline, status
12
- -- §3 RLS: authenticated tenant CRUD on both tables (the backoffice)
13
- -- §4 v_public_blog_posts — anon-readable view of PUBLISHED posts (the site)
14
- --
15
- -- Anon-access design mirrors plugin-agenda's public booking surface: the public
16
- -- website runs with the publishable key and no session, so canonical tenant RLS
17
- -- yields nothing. Anon read is granted ONLY through the column-whitelisted,
18
- -- published-only view (§4); the front provider filters it by tenant_id.
19
- -- Idempotent + safe to re-run.
20
- -- ============================================================================
21
-
22
- -- §1 — categories
23
- CREATE TABLE IF NOT EXISTS public.plg_blog_categories (
24
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
25
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
26
- name text NOT NULL,
27
- slug text NOT NULL,
28
- description text,
29
- created_at timestamptz NOT NULL DEFAULT now(),
30
- updated_at timestamptz NOT NULL DEFAULT now()
31
- );
32
- ALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;
33
- CREATE INDEX IF NOT EXISTS idx_plg_blog_categories_tenant ON public.plg_blog_categories(tenant_id);
34
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories(tenant_id, slug);
35
-
36
- -- §2 — posts
37
- CREATE TABLE IF NOT EXISTS public.plg_blog_posts (
38
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
39
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
40
- category_id uuid REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL,
41
- slug text NOT NULL,
42
- title text NOT NULL,
43
- excerpt text NOT NULL DEFAULT '',
44
- body text NOT NULL DEFAULT '',
45
- cover_image text,
46
- author_name text,
47
- author_role text,
48
- author_avatar_url text,
49
- author_bio text,
50
- status text NOT NULL DEFAULT 'draft'
51
- CHECK (status IN ('draft', 'published')),
52
- read_time text,
53
- published_at timestamptz,
54
- created_at timestamptz NOT NULL DEFAULT now(),
55
- updated_at timestamptz NOT NULL DEFAULT now()
56
- );
57
- ALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;
58
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_tenant ON public.plg_blog_posts(tenant_id);
59
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_category ON public.plg_blog_posts(category_id);
60
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_status ON public.plg_blog_posts(tenant_id, status, published_at DESC);
61
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts(tenant_id, slug);
62
-
63
- -- §3 — RLS: authenticated tenant CRUD (the backoffice writes here)
64
- DROP POLICY IF EXISTS plg_blog_categories_select ON public.plg_blog_categories;
65
- DROP POLICY IF EXISTS plg_blog_categories_insert ON public.plg_blog_categories;
66
- DROP POLICY IF EXISTS plg_blog_categories_update ON public.plg_blog_categories;
67
- DROP POLICY IF EXISTS plg_blog_categories_delete ON public.plg_blog_categories;
68
- CREATE POLICY plg_blog_categories_select ON public.plg_blog_categories FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
69
- CREATE POLICY plg_blog_categories_insert ON public.plg_blog_categories FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
70
- CREATE POLICY plg_blog_categories_update ON public.plg_blog_categories FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
71
- CREATE POLICY plg_blog_categories_delete ON public.plg_blog_categories FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
72
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_categories TO authenticated;
73
-
74
- DROP POLICY IF EXISTS plg_blog_posts_select ON public.plg_blog_posts;
75
- DROP POLICY IF EXISTS plg_blog_posts_insert ON public.plg_blog_posts;
76
- DROP POLICY IF EXISTS plg_blog_posts_update ON public.plg_blog_posts;
77
- DROP POLICY IF EXISTS plg_blog_posts_delete ON public.plg_blog_posts;
78
- CREATE POLICY plg_blog_posts_select ON public.plg_blog_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
79
- CREATE POLICY plg_blog_posts_insert ON public.plg_blog_posts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
80
- CREATE POLICY plg_blog_posts_update ON public.plg_blog_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
81
- CREATE POLICY plg_blog_posts_delete ON public.plg_blog_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
82
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_posts TO authenticated;
83
-
84
- -- §4 — anon-readable public catalog (owner-rights view = deliberate, whitelisted,
85
- -- PUBLISHED only). The front provider filters by tenant_id. Category name is
86
- -- surfaced as \`tag\` so the website's BlogPost shape stays unchanged.
87
- CREATE OR REPLACE VIEW public.v_public_blog_posts AS
88
- SELECT
89
- p.id,
90
- p.tenant_id,
91
- p.slug,
92
- p.title,
93
- p.excerpt,
94
- p.body,
95
- COALESCE(c.name, '') AS tag,
96
- p.cover_image,
97
- p.author_name,
98
- p.author_role,
99
- p.author_avatar_url,
100
- p.author_bio,
101
- p.read_time,
102
- p.published_at
103
- FROM public.plg_blog_posts p
104
- LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
105
- WHERE p.status = 'published';
106
-
107
- GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
108
- `
109
-
110
- export const MIGRATIONS: Array<{ id: string; sql: string }> = [
111
- { id: "001_blog", sql: MIGRATION_001_BLOG },
112
- ]
package/src/seo.ts DELETED
@@ -1,63 +0,0 @@
1
- import { absoluteUrl, breadcrumbJsonLd, organizationJsonLd } from '@fayz-ai/core'
2
- import type { BlogPost } from './types'
3
-
4
- // ---------------------------------------------------------------------------
5
- // Blog-specific schema.org builders. The head-manager mechanics (useSeo /
6
- // applySeo / meta+OG+Twitter) live in @fayz-ai/core — this only owns the
7
- // domain shapes (BlogPosting, Blog) that plug into core's SeoConfig.jsonLd.
8
- // ---------------------------------------------------------------------------
9
-
10
- /** Absolute canonical URL for a post. */
11
- export function postUrl(basePath: string, slug: string): string {
12
- return absoluteUrl(`${basePath}/${slug}`)
13
- }
14
-
15
- /** schema.org BlogPosting + BreadcrumbList for an article page. */
16
- export function articleJsonLd(post: BlogPost, opts: { basePath: string; siteName: string }): Array<Record<string, unknown>> {
17
- const url = postUrl(opts.basePath, post.slug)
18
- const posting: Record<string, unknown> = {
19
- '@context': 'https://schema.org',
20
- '@type': 'BlogPosting',
21
- headline: post.title,
22
- description: post.excerpt,
23
- image: post.image ? [post.image] : undefined,
24
- articleSection: post.tag,
25
- mainEntityOfPage: { '@type': 'WebPage', '@id': url },
26
- author: {
27
- '@type': 'Person',
28
- name: post.author.name,
29
- ...(post.author.role ? { jobTitle: post.author.role } : {}),
30
- },
31
- ...(post.publishedAt ? { datePublished: post.publishedAt, dateModified: post.publishedAt } : {}),
32
- ...(opts.siteName ? { publisher: organizationJsonLd(opts.siteName) } : {}),
33
- }
34
- const breadcrumb = breadcrumbJsonLd([
35
- { name: 'Blog', url: absoluteUrl(opts.basePath) },
36
- { name: post.title, url },
37
- ])
38
- return [posting, breadcrumb]
39
- }
40
-
41
- /** schema.org Blog + post list for the listing page. */
42
- export function blogJsonLd(posts: BlogPost[], opts: { basePath: string; siteName: string; title: string }): Array<Record<string, unknown>> {
43
- return [
44
- {
45
- '@context': 'https://schema.org',
46
- '@type': 'Blog',
47
- name: opts.title,
48
- url: absoluteUrl(opts.basePath),
49
- ...(opts.siteName ? { publisher: organizationJsonLd(opts.siteName) } : {}),
50
- blogPost: posts.map((p) => ({
51
- '@type': 'BlogPosting',
52
- headline: p.title,
53
- url: postUrl(opts.basePath, p.slug),
54
- image: p.image,
55
- author: { '@type': 'Person', name: p.author.name },
56
- ...(p.publishedAt ? { datePublished: p.publishedAt } : {}),
57
- })),
58
- },
59
- ]
60
- }
61
-
62
- // Re-export the generic hook so blog components import SEO from one place.
63
- export { useSeo } from '@fayz-ai/core'
package/src/types.ts DELETED
@@ -1,58 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Blog domain types. Deliberately presentation-agnostic: a host site maps these
3
- // into its own card markup, and the plugin's default components render them with
4
- // semantic Tailwind tokens so they inherit the host theme.
5
- // ---------------------------------------------------------------------------
6
-
7
- /** Medium-inspired author byline shown on cards and article pages. */
8
- export interface BlogAuthor {
9
- name: string
10
- /** Avatar image URL; a name-initial is shown as fallback. */
11
- avatarUrl?: string
12
- /** Short role/handle under the name (e.g. "Cirurgião-Dentista"). */
13
- role?: string
14
- /** One-line bio for the "about the author" block on the article page. */
15
- bio?: string
16
- }
17
-
18
- /**
19
- * First-class blog category. Managed in the backoffice (marketing "Blog" tab);
20
- * a post references one via categoryId. The website's BlogPost surfaces the
21
- * category's `name` as its `tag` string so presentation stays unchanged.
22
- */
23
- export interface BlogCategory {
24
- id: string
25
- name: string
26
- /** URL-safe identifier (unique per tenant). */
27
- slug: string
28
- description?: string
29
- }
30
-
31
- export interface BlogPost {
32
- /** URL-safe identifier used for the /blog/:slug detail route. */
33
- slug: string
34
- title: string
35
- /** Short summary shown on cards / list. */
36
- excerpt: string
37
- /** Full article body. Plain text or lightweight markdown (paragraphs split on blank lines). */
38
- body: string
39
- /** Category / topic label (e.g. "Periodontia"). Derived from the post's category name. */
40
- tag: string
41
- /** Human-readable reading time (e.g. "5 min"). */
42
- readTime: string
43
- /** Human-readable publish date (e.g. "Janeiro 2025"). */
44
- date: string
45
- /** Machine date (ISO 8601) for SEO/JSON-LD datePublished. Optional. */
46
- publishedAt?: string
47
- /** Cover image URL. */
48
- image: string
49
- /** Byline author. Filled from the plugin's defaultAuthor when a post omits it. */
50
- author: BlogAuthor
51
- }
52
-
53
- export interface BlogListQuery {
54
- /** Cap the number of posts returned (e.g. the home teaser shows 3). */
55
- limit?: number
56
- /** Filter by tag. */
57
- tag?: string
58
- }