@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,4 +1,5 @@
1
1
  export declare const MIGRATION_001_BLOG = "-- ============================================================================\n-- plugin-blog 001: blog content model + public (anon) read surface\n--\n-- Backs the blog backoffice (managed from the marketing plugin's \"Blog\" tab) and\n-- the public website surface (/blog, /blog/:slug). Prefix: plg_blog_.\n-- \u00A71 plg_blog_categories \u2014 first-class category (name/slug/description)\n-- \u00A72 plg_blog_posts \u2014 articles; category_id FK, author byline, status\n-- \u00A73 RLS: authenticated tenant CRUD on both tables (the backoffice)\n-- \u00A74 v_public_blog_posts \u2014 anon-readable view of PUBLISHED posts (the site)\n--\n-- Anon-access design mirrors plugin-agenda's public booking surface: the public\n-- website runs with the publishable key and no session, so canonical tenant RLS\n-- yields nothing. Anon read is granted ONLY through the column-whitelisted,\n-- published-only view (\u00A74); the front provider filters it by tenant_id.\n-- Idempotent + safe to re-run.\n-- ============================================================================\n\n-- \u00A71 \u2014 categories\nCREATE TABLE IF NOT EXISTS public.plg_blog_categories (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n slug text NOT NULL,\n description text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_blog_categories_tenant ON public.plg_blog_categories(tenant_id);\nCREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories(tenant_id, slug);\n\n-- \u00A72 \u2014 posts\nCREATE TABLE IF NOT EXISTS public.plg_blog_posts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n category_id uuid REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL,\n slug text NOT NULL,\n title text NOT NULL,\n excerpt text NOT NULL DEFAULT '',\n body text NOT NULL DEFAULT '',\n cover_image text,\n author_name text,\n author_role text,\n author_avatar_url text,\n author_bio text,\n status text NOT NULL DEFAULT 'draft'\n CHECK (status IN ('draft', 'published')),\n read_time text,\n published_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_blog_posts_tenant ON public.plg_blog_posts(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_blog_posts_category ON public.plg_blog_posts(category_id);\nCREATE INDEX IF NOT EXISTS idx_plg_blog_posts_status ON public.plg_blog_posts(tenant_id, status, published_at DESC);\nCREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts(tenant_id, slug);\n\n-- \u00A73 \u2014 RLS: authenticated tenant CRUD (the backoffice writes here)\nDROP POLICY IF EXISTS plg_blog_categories_select ON public.plg_blog_categories;\nDROP POLICY IF EXISTS plg_blog_categories_insert ON public.plg_blog_categories;\nDROP POLICY IF EXISTS plg_blog_categories_update ON public.plg_blog_categories;\nDROP POLICY IF EXISTS plg_blog_categories_delete ON public.plg_blog_categories;\nCREATE POLICY plg_blog_categories_select ON public.plg_blog_categories FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_blog_categories_insert ON public.plg_blog_categories FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_blog_categories_update ON public.plg_blog_categories FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_blog_categories_delete ON public.plg_blog_categories FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_categories TO authenticated;\n\nDROP POLICY IF EXISTS plg_blog_posts_select ON public.plg_blog_posts;\nDROP POLICY IF EXISTS plg_blog_posts_insert ON public.plg_blog_posts;\nDROP POLICY IF EXISTS plg_blog_posts_update ON public.plg_blog_posts;\nDROP POLICY IF EXISTS plg_blog_posts_delete ON public.plg_blog_posts;\nCREATE POLICY plg_blog_posts_select ON public.plg_blog_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_blog_posts_insert ON public.plg_blog_posts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_blog_posts_update ON public.plg_blog_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_blog_posts_delete ON public.plg_blog_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_posts TO authenticated;\n\n-- \u00A74 \u2014 anon-readable public catalog (owner-rights view = deliberate, whitelisted,\n-- PUBLISHED only). The front provider filters by tenant_id. Category name is\n-- surfaced as `tag` so the website's BlogPost shape stays unchanged.\nCREATE OR REPLACE VIEW public.v_public_blog_posts AS\nSELECT\n p.id,\n p.tenant_id,\n p.slug,\n p.title,\n p.excerpt,\n p.body,\n COALESCE(c.name, '') AS tag,\n p.cover_image,\n p.author_name,\n p.author_role,\n p.author_avatar_url,\n p.author_bio,\n p.read_time,\n p.published_at\nFROM public.plg_blog_posts p\nLEFT JOIN public.plg_blog_categories c ON c.id = p.category_id\nWHERE p.status = 'published';\n\nGRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;\n";
2
+ export declare const MIGRATION_002_EXTERNAL_POSTS = "-- ============================================================================\n-- plugin-blog 002: the ingest seam \u2014 per-post SEO, external identity, and the\n-- single function an outside publisher passes through.\n--\n-- 001 gave the blog a content model written by one hand: the backoffice, behind\n-- a session, through RLS. RankLayer publishes INTO the tenant instead, so the\n-- write arrives from a machine we do not run, carrying HTML we did not author\n-- and an identity that lives in someone else's database. Three things follow,\n-- and each is a section here.\n--\n-- \u00A71 columns per-post SEO + the external identity (external_ref, source\u2026)\n-- \u00A72 identity partial unique index on (tenant_id, external_ref)\n-- \u00A73 sanitiser plg_blog_sanitize_html \u2014 a tag allowlist, in SQL\n-- \u00A74 ingest blog_upsert_external_post(p_payload jsonb)\n-- \u00A75 agent agent_blog_draft_post \u2014 the assistant's write, guarded\n-- \u00A76 facts blog.post.{created,updated,published,deleted}\n-- \u00A77 view v_public_blog_posts extended with the SEO the site renders\n--\n-- Additive and idempotent: no table is created, no policy is rewritten, the\n-- existing tenant RLS from 001 already covers every column added here.\n-- ============================================================================\n\n-- \u00A71 \u2500\u2500 columns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Per-post SEO. The blog already had site-level SEO (lib/seo.ts builds the\n-- descriptors), but an organic-SEO product's whole output is per-ARTICLE: the\n-- title it wants in the SERP is not the title on the page, and a syndicated\n-- post has a canonical that points somewhere else entirely.\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS meta_title text;\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS meta_description text;\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS canonical_url text;\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS og_image text;\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS noindex boolean NOT NULL DEFAULT false;\n\n-- The external identity. `external_ref` is the publisher's id for the article \u2014\n-- ours is a uuid it has never seen, so re-delivery can only be recognised by\n-- theirs. `source` is the anti-loop field: it names who WROTE the post, and a\n-- connector reads it to avoid echoing an article back to the system it came\n-- from. Defaulting to 'admin' is not a placeholder \u2014 every row that exists\n-- today was written by a person in the backoffice, and saying so is what makes\n-- the field usable from the first event.\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS external_ref text;\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'admin';\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS language text;\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS tags text[];\nALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS author_member_id uuid\n REFERENCES public.tenant_members(id) ON DELETE SET NULL;\n\n-- \u00A72 \u2500\u2500 identity \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- This index is the whole of idempotency. Without it the same article delivered\n-- twice \u2014 a retry, a redeployed connector, a replayed queue \u2014 lands as two\n-- posts with two slugs, and the tenant's blog grows duplicates nobody ordered.\n-- Partial because the backoffice's own posts have no external ref and must not\n-- collide with each other on NULL.\nCREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_external_ref\n ON public.plg_blog_posts (tenant_id, external_ref)\n WHERE external_ref IS NOT NULL;\n\n-- \"What did this source publish here?\" is the connector's own reconciliation\n-- query, and the ledger's.\nCREATE INDEX IF NOT EXISTS idx_plg_blog_posts_source\n ON public.plg_blog_posts (tenant_id, source, updated_at DESC);\n\n-- \u00A73 \u2500\u2500 sanitiser \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- The sanitising happens HERE, in the function, and not in the client that\n-- renders the post. The RPC below is the only door an external publisher passes\n-- through; a browser-side sanitiser protects the one reader whose browser runs\n-- it and nothing else \u2014 the row is already poisoned, the RSS feed, the sitemap\n-- and every server-rendered page still carry the script. A control has to sit\n-- where the data enters, not where it is displayed.\n\n-- Extract one attribute from a tag, tolerating the three ways HTML quotes a\n-- value. `p_name` is always a literal this file controls, never caller input.\nCREATE OR REPLACE FUNCTION public.plg_blog_html_attr(p_tag text, p_name text)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nAS $fn$\n SELECT coalesce(\n substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*\"([^\"]*)\"'),\n substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*''([^'']*)'''),\n substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*([^\\s\">]+)')\n );\n$fn$;\n\nCREATE OR REPLACE FUNCTION public.plg_blog_html_escape(p_text text)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nAS $fn$\n SELECT replace(replace(replace(coalesce(p_text, ''), '<', '&lt;'), '>', '&gt;'), '\"', '&quot;');\n$fn$;\n\n-- A URL we are willing to put back into the document.\nCREATE OR REPLACE FUNCTION public.plg_blog_safe_url(p_url text)\nRETURNS text\nLANGUAGE plpgsql\nIMMUTABLE\nAS $fn$\nDECLARE\n v text;\nBEGIN\n IF p_url IS NULL THEN RETURN NULL; END IF;\n -- Whitespace and control characters go BEFORE the scheme test: `java\\tscript:`\n -- is a working URL in every browser and would pass a naive prefix check.\n v := regexp_replace(btrim(p_url), '[[:space:][:cntrl:]]', '', 'g');\n IF v = '' THEN RETURN NULL; END IF;\n -- Allowlist of schemes. `data:` is absent on purpose: a data URL is a way to\n -- ship a whole document \u2014 including an SVG that scripts \u2014 inside an href.\n IF v ~* '^(https?://|mailto:|/|#)' THEN\n RETURN public.plg_blog_html_escape(v);\n END IF;\n RETURN NULL;\nEND;\n$fn$;\n\nCREATE OR REPLACE FUNCTION public.plg_blog_sanitize_html(p_html text)\nRETURNS text\nLANGUAGE plpgsql\nIMMUTABLE\nSET search_path = public\nAS $fn$\nDECLARE\n -- The allowlist is what an ARTICLE is made of. Layout tags (div, span,\n -- section, main) are deliberately out: they carry nothing a blog post needs\n -- and every one of them is a place to hang an attribute. Anything not listed\n -- loses its tag and keeps its text, so a stripped wrapper degrades to prose\n -- rather than to a hole in the article.\n c_allowed constant text[] := ARRAY[\n 'p','br','hr','h1','h2','h3','h4','h5','h6',\n 'strong','b','em','i','u','s','sup','sub','code','pre','blockquote','q','cite',\n 'ul','ol','li','dl','dt','dd',\n 'a','img','figure','figcaption',\n 'table','thead','tbody','tfoot','tr','th','td','caption'\n ];\n c_void constant text[] := ARRAY['br','hr','img'];\n -- Elements whose CONTENT is code rather than text.\n c_active constant text :=\n 'script|style|iframe|object|embed|applet|form|noscript|svg|math|template|frameset|frame';\n v text;\n v_rest text;\n v_out text := '';\n v_lt int;\n v_gt int;\n v_tag text;\n v_name text;\n v_val text;\n v_attrs text;\nBEGIN\n IF p_html IS NULL OR btrim(p_html) = '' THEN RETURN ''; END IF;\n v := p_html;\n\n -- Comments first: a conditional comment is executable markup in older\n -- engines, and `<!-- <script>\u2026 -->` would otherwise reach the pass below\n -- already commented out and survive it.\n v := regexp_replace(v, '<!--.*?-->', '', 'g');\n v := regexp_replace(v, '<!\\[CDATA\\[.*?\\]\\]>', '', 'g');\n v := regexp_replace(v, '<\\?.*?\\?>', '', 'g');\n\n -- Active elements go whole. Dropping only the tag would leave the script body\n -- standing as visible page text \u2014 the article would read as vandalised even\n -- though nothing executed.\n -- Two Postgres regex traps, both of which fail OPEN and were caught by\n -- running this against a container rather than by reading it:\n -- `\\y`, not `\\b` \u2014 in a Postgres RE `\\b` is the BACKSPACE character, so\n -- `<script\\b` matches nothing and the script body survives as page text.\n -- Every quantifier non-greedy \u2014 a Postgres RE takes its greediness from its\n -- FIRST quantifier, so one greedy `\\s*` at the front makes the trailing\n -- `.*?` greedy too, and the first <script> swallows the article down to the\n -- last closing tag in the document.\n v := regexp_replace(v, '<\\s*?(' || c_active || ')\\y[^>]*?>.*?<\\s*?/\\s*?\\1\\s*?>', '', 'gi');\n -- \u2026and the unbalanced halves of the same set, plus the document-level tags\n -- that have no business inside an article body.\n v := regexp_replace(\n v,\n '<\\s*/?\\s*(' || c_active || '|link|meta|base|title|head|body|html)\\y[^>]*>',\n '', 'gi');\n\n -- Walk the remaining tags. A rewrite rather than a filter: every tag that\n -- comes out the other side was BUILT here from a name and an allowlisted\n -- attribute, so nothing an attacker wrote is copied through by accident.\n v_rest := v;\n LOOP\n v_lt := position('<' in v_rest);\n IF v_lt = 0 THEN\n v_out := v_out || v_rest;\n EXIT;\n END IF;\n v_out := v_out || substr(v_rest, 1, v_lt - 1);\n v_rest := substr(v_rest, v_lt);\n\n v_gt := position('>' in v_rest);\n IF v_gt = 0 THEN\n -- An unterminated '<' is text. It is escaped so it cannot open a tag when\n -- something is concatenated after this body.\n v_out := v_out || replace(v_rest, '<', '&lt;');\n EXIT;\n END IF;\n v_tag := substr(v_rest, 1, v_gt);\n v_rest := substr(v_rest, v_gt + 1);\n\n v_name := lower(coalesce(substring(v_tag from '^<\\s*/?\\s*([a-zA-Z][a-zA-Z0-9]*)'), ''));\n IF v_name = '' OR NOT (v_name = ANY (c_allowed)) THEN\n CONTINUE; -- tag dropped, its text kept\n END IF;\n\n IF v_tag ~ '^<\\s*/' THEN\n IF NOT (v_name = ANY (c_void)) THEN v_out := v_out || '</' || v_name || '>'; END IF;\n CONTINUE;\n END IF;\n\n -- Attributes are an allowlist too, and a per-tag one: `href` on <a>,\n -- `src`/`alt` on <img>. Everything else \u2014 style, on*, srcset, class,\n -- data-* \u2014 is dropped rather than inspected, because inspecting means\n -- maintaining a list of what is dangerous, and that list is never finished.\n v_attrs := '';\n IF v_name = 'a' THEN\n v_val := public.plg_blog_safe_url(public.plg_blog_html_attr(v_tag, 'href'));\n IF v_val IS NOT NULL THEN\n -- An external publisher's outbound links are not the tenant's\n -- endorsement, and an unmarked link farm is precisely the SEO damage\n -- this seam could otherwise cause on the tenant's own domain.\n v_attrs := ' href=\"' || v_val || '\" rel=\"nofollow noopener\"';\n END IF;\n ELSIF v_name = 'img' THEN\n v_val := public.plg_blog_safe_url(public.plg_blog_html_attr(v_tag, 'src'));\n IF v_val IS NULL THEN CONTINUE; END IF; -- an <img> with no safe src is nothing\n v_attrs := ' src=\"' || v_val || '\" alt=\"'\n || public.plg_blog_html_escape(public.plg_blog_html_attr(v_tag, 'alt')) || '\"';\n END IF;\n\n v_out := v_out || '<' || v_name || v_attrs\n || CASE WHEN v_name = ANY (c_void) THEN ' />' ELSE '>' END;\n END LOOP;\n\n RETURN v_out;\nEND;\n$fn$;\n\n-- Accents are transliterated rather than stripped: 'sa\u00FAde-bucal' beats\n-- 'sa-de-bucal', and a slug is a URL somebody will read out loud.\nCREATE OR REPLACE FUNCTION public.plg_blog_slugify(p_text text)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nAS $fn$\n SELECT btrim(\n regexp_replace(\n translate(lower(coalesce(p_text, '')),\n '\u00E1\u00E0\u00E2\u00E3\u00E4\u00E5\u00E9\u00E8\u00EA\u00EB\u00ED\u00EC\u00EE\u00EF\u00F3\u00F2\u00F4\u00F5\u00F6\u00F8\u00FA\u00F9\u00FB\u00FC\u00E7\u00F1\u00FD\u00FF',\n 'aaaaaaeeeeiiiioooooouuuucnyy'),\n '[^a-z0-9]+', '-', 'g'),\n '-');\n$fn$;\n\nREVOKE ALL ON FUNCTION public.plg_blog_html_attr(text, text) FROM public, anon;\nREVOKE ALL ON FUNCTION public.plg_blog_html_escape(text) FROM public, anon;\nREVOKE ALL ON FUNCTION public.plg_blog_safe_url(text) FROM public, anon;\nREVOKE ALL ON FUNCTION public.plg_blog_sanitize_html(text) FROM public, anon;\nREVOKE ALL ON FUNCTION public.plg_blog_slugify(text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.plg_blog_html_attr(text, text) TO authenticated, service_role;\nGRANT EXECUTE ON FUNCTION public.plg_blog_html_escape(text) TO authenticated, service_role;\nGRANT EXECUTE ON FUNCTION public.plg_blog_safe_url(text) TO authenticated, service_role;\nGRANT EXECUTE ON FUNCTION public.plg_blog_sanitize_html(text) TO authenticated, service_role;\nGRANT EXECUTE ON FUNCTION public.plg_blog_slugify(text) TO authenticated, service_role;\n\n-- \u00A74 \u2500\u2500 ingest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- The one door. Takes a single jsonb so the caller never has to know a column\n-- list, and returns what it did so a connector can reconcile without a second\n-- read.\n--\n-- Payload: external_ref, title, slug, excerpt, body_html, status, source,\n-- language, tags[], author_member_id, published_at, cover_image, meta_title,\n-- meta_description, canonical_url, og_image, noindex.\nCREATE OR REPLACE FUNCTION public.blog_upsert_external_post(p_payload jsonb)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $fn$\nDECLARE\n v_tenants uuid[];\n v_tenant uuid;\n v_claimed uuid;\n v_ref text;\n v_title text;\n v_base text;\n v_slug text;\n v_status text;\n v_source text;\n v_lang text;\n v_body text;\n v_excerpt text;\n v_tags text[];\n v_author uuid;\n v_pub timestamptz;\n v_cover text;\n v_existing_id uuid;\n v_existing_pub timestamptz;\n v_existing_slug text;\n v_n int := 1;\n v_id uuid;\n v_created boolean := false;\nBEGIN\n -- \u2500\u2500 tenant: from the credential, never from the payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n -- A tenant id the caller supplies is how one tenant writes into another's\n -- blog, and the caller is a machine outside our trust boundary. The only\n -- statement about identity we can believe is the one the credential makes.\n SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;\n IF v_tenants IS NULL OR array_length(v_tenants, 1) <> 1 THEN\n RAISE EXCEPTION\n 'blog_upsert_external_post: the caller must present a credential scoped to exactly one tenant'\n USING ERRCODE = 'insufficient_privilege';\n END IF;\n v_tenant := v_tenants[1];\n\n -- Refusing beats ignoring: a publisher that believes it is writing into\n -- another tenant must be told so, not silently redirected into this one.\n IF nullif(btrim(p_payload->>'tenant_id'), '') IS NOT NULL THEN\n BEGIN\n v_claimed := (p_payload->>'tenant_id')::uuid;\n EXCEPTION WHEN OTHERS THEN\n v_claimed := NULL;\n END;\n IF v_claimed IS DISTINCT FROM v_tenant THEN\n RAISE EXCEPTION 'blog_upsert_external_post: payload names another tenant; the tenant comes from the credential'\n USING ERRCODE = 'insufficient_privilege';\n END IF;\n END IF;\n\n -- \u2500\u2500 the article \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n v_ref := nullif(btrim(p_payload->>'external_ref'), '');\n v_title := nullif(btrim(p_payload->>'title'), '');\n IF v_title IS NULL THEN\n RAISE EXCEPTION 'blog_upsert_external_post: title is required'\n USING ERRCODE = 'invalid_parameter_value';\n END IF;\n\n v_status := lower(coalesce(nullif(btrim(p_payload->>'status'), ''), 'draft'));\n IF v_status NOT IN ('draft', 'published') THEN\n RAISE EXCEPTION 'blog_upsert_external_post: unknown status %; expected draft or published', v_status\n USING ERRCODE = 'invalid_parameter_value';\n END IF;\n\n -- Never taken as 'admin' by default: a post that arrived through this door is\n -- external by definition, and mislabelling it is what makes a connector echo\n -- it back to the system that sent it.\n v_source := lower(coalesce(nullif(btrim(p_payload->>'source'), ''), 'external'));\n v_lang := nullif(btrim(p_payload->>'language'), '');\n v_body := public.plg_blog_sanitize_html(p_payload->>'body_html');\n v_excerpt := left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500);\n\n IF jsonb_typeof(p_payload->'tags') = 'array' THEN\n SELECT array_agg(btrim(x)) INTO v_tags\n FROM jsonb_array_elements_text(p_payload->'tags') AS x\n WHERE btrim(x) <> '';\n END IF;\n\n -- An author id is a claim about a person in THIS tenant. Unverified, it would\n -- let an external publisher put a real employee's name on anything it writes.\n IF nullif(btrim(p_payload->>'author_member_id'), '') IS NOT NULL THEN\n BEGIN\n SELECT tm.id INTO v_author\n FROM public.tenant_members tm\n WHERE tm.tenant_id = v_tenant AND tm.id = (p_payload->>'author_member_id')::uuid;\n EXCEPTION WHEN OTHERS THEN\n v_author := NULL;\n END;\n END IF;\n\n v_cover := public.plg_blog_safe_url(\n coalesce(nullif(btrim(p_payload->>'cover_image'), ''), nullif(btrim(p_payload->>'og_image'), '')));\n\n -- \u2500\u2500 identity + slug \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n IF v_ref IS NOT NULL THEN\n SELECT p.id, p.published_at, p.slug INTO v_existing_id, v_existing_pub, v_existing_slug\n FROM public.plg_blog_posts p\n WHERE p.tenant_id = v_tenant AND p.external_ref = v_ref;\n END IF;\n\n -- On re-delivery the existing slug WINS over one re-derived from the title.\n -- A published post's slug is its URL: silently re-slugging it because the\n -- publisher fixed a typo in the headline breaks every inbound link \u2014 on an\n -- SEO product's own articles, of all things. Changing it stays possible, but\n -- only by saying so explicitly in `slug`.\n v_base := coalesce(\n nullif(public.plg_blog_slugify(p_payload->>'slug'), ''),\n nullif(v_existing_slug, ''),\n nullif(public.plg_blog_slugify(v_title), ''),\n 'post-' || left(md5(coalesce(v_ref, gen_random_uuid()::text)), 8));\n v_base := left(v_base, 120);\n\n -- Slug is unique per tenant (001). Two different articles that slugify the\n -- same must not make the second delivery fail \u2014 a suffixed slug is a smaller\n -- surprise than a rejected publish.\n v_slug := v_base;\n WHILE EXISTS (\n SELECT 1 FROM public.plg_blog_posts p\n WHERE p.tenant_id = v_tenant AND p.slug = v_slug\n AND (v_existing_id IS NULL OR p.id <> v_existing_id)\n ) LOOP\n v_n := v_n + 1;\n IF v_n > 50 THEN\n RAISE EXCEPTION 'blog_upsert_external_post: cannot derive a free slug from %', v_base\n USING ERRCODE = 'invalid_parameter_value';\n END IF;\n v_slug := left(v_base, 112) || '-' || v_n;\n END LOOP;\n\n BEGIN\n v_pub := nullif(btrim(p_payload->>'published_at'), '')::timestamptz;\n EXCEPTION WHEN OTHERS THEN\n v_pub := NULL;\n END;\n -- A post that goes back to draft keeps the date it first went out; the public\n -- view filters on status, so nothing leaks, and the history stays true.\n v_pub := coalesce(v_pub, v_existing_pub, CASE WHEN v_status = 'published' THEN now() END);\n\n -- \u2500\u2500 write \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n IF v_existing_id IS NOT NULL THEN\n UPDATE public.plg_blog_posts SET\n slug = v_slug,\n title = v_title,\n excerpt = v_excerpt,\n body = v_body,\n status = v_status,\n published_at = v_pub,\n source = v_source,\n language = v_lang,\n tags = v_tags,\n author_member_id = coalesce(v_author, author_member_id),\n cover_image = coalesce(v_cover, cover_image),\n meta_title = nullif(btrim(p_payload->>'meta_title'), ''),\n meta_description = nullif(btrim(p_payload->>'meta_description'), ''),\n canonical_url = public.plg_blog_safe_url(p_payload->>'canonical_url'),\n og_image = public.plg_blog_safe_url(p_payload->>'og_image'),\n noindex = coalesce((p_payload->>'noindex')::boolean, false),\n updated_at = now()\n WHERE id = v_existing_id;\n v_id := v_existing_id;\n ELSE\n -- ON CONFLICT on the partial index, not just the SELECT above: two\n -- deliveries of the same article racing each other would both find nothing\n -- and both insert. The index is what makes the second one an update.\n INSERT INTO public.plg_blog_posts (\n tenant_id, slug, title, excerpt, body, status, published_at,\n external_ref, source, language, tags, author_member_id, cover_image,\n meta_title, meta_description, canonical_url, og_image, noindex, updated_at)\n VALUES (\n v_tenant, v_slug, v_title, v_excerpt, v_body, v_status, v_pub,\n v_ref, v_source, v_lang, v_tags, v_author, v_cover,\n nullif(btrim(p_payload->>'meta_title'), ''),\n nullif(btrim(p_payload->>'meta_description'), ''),\n public.plg_blog_safe_url(p_payload->>'canonical_url'),\n public.plg_blog_safe_url(p_payload->>'og_image'),\n coalesce((p_payload->>'noindex')::boolean, false),\n now())\n ON CONFLICT (tenant_id, external_ref) WHERE external_ref IS NOT NULL\n DO UPDATE SET\n title = excluded.title,\n excerpt = excluded.excerpt,\n body = excluded.body,\n status = excluded.status,\n published_at = coalesce(excluded.published_at, plg_blog_posts.published_at),\n source = excluded.source,\n language = excluded.language,\n tags = excluded.tags,\n cover_image = coalesce(excluded.cover_image, plg_blog_posts.cover_image),\n meta_title = excluded.meta_title,\n meta_description = excluded.meta_description,\n canonical_url = excluded.canonical_url,\n og_image = excluded.og_image,\n noindex = excluded.noindex,\n updated_at = now()\n RETURNING id, (xmax = 0) INTO v_id, v_created;\n END IF;\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_id,\n 'slug', v_slug,\n 'status', v_status,\n 'source', v_source,\n 'external_ref', v_ref,\n 'created', coalesce(v_created, false));\nEND;\n$fn$;\n\nREVOKE ALL ON FUNCTION public.blog_upsert_external_post(jsonb) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.blog_upsert_external_post(jsonb) TO authenticated, service_role;\n\n-- \u00A75 \u2500\u2500 agent \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- The assistant's write, in the shape the broker calls (p_tenant_id,\n-- p_actor_user_id, p_payload) \u2014 a different signature from \u00A74 on purpose: the\n-- broker injects a verified actor and agent_guard runs role \u2192 plan \u2192 the\n-- blog_posts cap before anything is written. A draft, never a publish: an agent\n-- that can put a page on the public site without anyone reading it first is a\n-- worse product than one that cannot.\nCREATE OR REPLACE FUNCTION public.agent_blog_draft_post(\n p_tenant_id uuid,\n p_actor_user_id uuid,\n p_payload jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $fn$\nDECLARE\n v_denial jsonb;\n v_used int;\n v_title text;\n v_base text;\n v_slug text;\n v_n int := 1;\n v_id uuid;\nBEGIN\n v_title := nullif(btrim(p_payload->>'title'), '');\n IF v_title IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'title is required to draft a post');\n END IF;\n\n SELECT count(*) INTO v_used FROM public.plg_blog_posts p WHERE p.tenant_id = p_tenant_id;\n v_denial := public.agent_guard(p_tenant_id, p_actor_user_id, 'blog', 'create', 'blog_posts', v_used, 1);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n v_base := coalesce(nullif(public.plg_blog_slugify(v_title), ''), 'post-' || left(md5(v_title), 8));\n v_slug := left(v_base, 120);\n WHILE EXISTS (SELECT 1 FROM public.plg_blog_posts p\n WHERE p.tenant_id = p_tenant_id AND p.slug = v_slug) LOOP\n v_n := v_n + 1;\n IF v_n > 50 THEN\n RETURN jsonb_build_object('ok', false, 'error', format('cannot derive a free slug from %L', v_base));\n END IF;\n v_slug := left(v_base, 112) || '-' || v_n;\n END LOOP;\n\n INSERT INTO public.plg_blog_posts (\n tenant_id, slug, title, excerpt, body, status, source, language, tags, updated_at)\n VALUES (\n p_tenant_id, v_slug, v_title,\n left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500),\n -- Same sanitiser as the external door: the model's output is not more\n -- trustworthy than a partner's, it is just closer to home.\n public.plg_blog_sanitize_html(p_payload->>'body_html'),\n 'draft', 'agent',\n nullif(btrim(p_payload->>'language'), ''),\n CASE WHEN jsonb_typeof(p_payload->'tags') = 'array'\n THEN ARRAY(SELECT btrim(x) FROM jsonb_array_elements_text(p_payload->'tags') AS x WHERE btrim(x) <> '')\n END,\n now())\n RETURNING id INTO v_id;\n\n INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id, 'agent.draftBlogPost', 'blog_post', v_id::text,\n jsonb_build_object('title', v_title, 'slug', v_slug));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_id,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_id, 'resource', 'plg_blog_posts', 'entityKey', 'blog:posts'),\n 'name', v_title,\n 'slug', v_slug,\n 'status', 'draft'));\nEND;\n$fn$;\n\nREVOKE ALL ON FUNCTION public.agent_blog_draft_post(uuid, uuid, jsonb) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.agent_blog_draft_post(uuid, uuid, jsonb) TO authenticated, service_role;\n\n-- \u00A76 \u2500\u2500 facts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- One trigger on the table rather than an emit call inside each writer. The\n-- backoffice, this file's two RPCs and a hand-run SQL fix all end up in the\n-- same place, so a connector hears the same four facts no matter who wrote \u2014\n-- which is the entire reason the row-level emitter exists (packages/db/\n-- migrations/027_domain_events.sql).\n--\n-- `published` is not a row action, which is why the generic\n-- plg_attach_event_source trigger cannot produce it: it is a TRANSITION, and\n-- only something holding OLD and NEW can see it happen.\nCREATE OR REPLACE FUNCTION public.plg_blog_emit_post_event()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $fn$\nDECLARE\n v_row jsonb;\n v_tenant uuid;\n v_id text;\n v_payload jsonb;\nBEGIN\n -- A save that changed nothing is not a fact; without this any bulk re-save\n -- buries the real updates.\n IF TG_OP = 'UPDATE' AND OLD IS NOT DISTINCT FROM NEW THEN\n RETURN NULL;\n END IF;\n\n IF TG_OP = 'DELETE' THEN\n v_row := to_jsonb(OLD);\n ELSE\n v_row := to_jsonb(NEW);\n END IF;\n v_tenant := nullif(v_row->>'tenant_id', '')::uuid;\n v_id := v_row->>'id';\n -- The contract every consumer reads. Not the row: a whole-row payload puts\n -- the article body inside every webhook by default, and whoever needs it\n -- fetches by id.\n v_payload := jsonb_build_object(\n 'post_id', v_id,\n 'slug', v_row->>'slug',\n 'title', v_row->>'title',\n 'status', v_row->>'status',\n 'source', coalesce(v_row->>'source', 'admin'),\n 'external_ref', v_row->>'external_ref',\n 'language', v_row->>'language');\n\n BEGIN\n IF TG_OP = 'INSERT' THEN\n PERFORM public.plg_emit_event('blog.post.created', v_payload, 'blog_post', v_id, v_tenant, NULL);\n IF NEW.status = 'published' THEN\n PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);\n END IF;\n ELSIF TG_OP = 'UPDATE' THEN\n PERFORM public.plg_emit_event('blog.post.updated', v_payload, 'blog_post', v_id, v_tenant, NULL);\n IF NEW.status = 'published' AND OLD.status IS DISTINCT FROM 'published' THEN\n PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);\n END IF;\n ELSE\n PERFORM public.plg_emit_event('blog.post.deleted', v_payload, 'blog_post', v_id, v_tenant, NULL);\n END IF;\n EXCEPTION WHEN OTHERS THEN\n -- Observability must never be able to reject the write that caused it.\n RAISE WARNING '[plg_blog_emit_post_event] % on %: %', TG_OP, TG_TABLE_NAME, SQLERRM;\n END;\n\n RETURN NULL;\nEND;\n$fn$;\n\nREVOKE ALL ON FUNCTION public.plg_blog_emit_post_event() FROM public, anon;\n\nDROP TRIGGER IF EXISTS plg_blog_posts_emit_facts ON public.plg_blog_posts;\nCREATE TRIGGER plg_blog_posts_emit_facts\n AFTER INSERT OR UPDATE OR DELETE ON public.plg_blog_posts\n FOR EACH ROW EXECUTE FUNCTION public.plg_blog_emit_post_event();\n\n-- \u00A77 \u2500\u2500 view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- The public site runs anon and reads only this view, so per-post SEO that the\n-- view does not carry cannot be rendered \u2014 the columns would exist and no page\n-- would ever show them.\n--\n-- `source` and `external_ref` stay out: they are operational facts about who\n-- writes this tenant's blog, and the anon surface is the open internet.\n-- New columns are appended, never reordered \u2014 CREATE OR REPLACE VIEW only\n-- tolerates additions at the end.\nCREATE OR REPLACE VIEW public.v_public_blog_posts AS\nSELECT\n p.id,\n p.tenant_id,\n p.slug,\n p.title,\n p.excerpt,\n p.body,\n COALESCE(c.name, '') AS tag,\n p.cover_image,\n p.author_name,\n p.author_role,\n p.author_avatar_url,\n p.author_bio,\n p.read_time,\n p.published_at,\n p.meta_title,\n p.meta_description,\n p.canonical_url,\n p.og_image,\n p.noindex,\n p.language,\n p.tags\nFROM public.plg_blog_posts p\nLEFT JOIN public.plg_blog_categories c ON c.id = p.category_id\nWHERE p.status = 'published';\n\nGRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;\n";
2
3
  export declare const MIGRATIONS: Array<{
3
4
  id: string;
4
5
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,kBAAkB,qsLAuG9B,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAEzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,kBAAkB,qsLAuG9B,CAAA;AAED,eAAO,MAAM,4BAA4B,imiCA6qBxC,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAGzD,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fayz-ai/plugin-blog",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "[experimental] Fayz SDK — blog / articles plugin (website surface)",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -9,14 +9,12 @@
9
9
  "types": "./dist/index.d.ts",
10
10
  "exports": {
11
11
  ".": {
12
- "source": "./src/index.ts",
13
12
  "types": "./dist/index.d.ts",
14
13
  "import": "./dist/index.js"
15
14
  }
16
15
  },
17
16
  "files": [
18
- "dist",
19
- "src"
17
+ "dist"
20
18
  ],
21
19
  "peerDependencies": {
22
20
  "react": "^18.0.0 || ^19.0.0",
@@ -25,7 +23,7 @@
25
23
  "lucide-react": ">=0.400.0 <1.0.0"
26
24
  },
27
25
  "dependencies": {
28
- "@fayz-ai/core": "^0.9.0"
26
+ "@fayz-ai/core": "^0.11.0"
29
27
  },
30
28
  "devDependencies": {
31
29
  "@types/react": "^18.3.0",
@@ -35,7 +33,8 @@
35
33
  "tsup": "^8.2.0",
36
34
  "typescript": "^5.5.0",
37
35
  "@types/react-dom": "^18.3.0",
38
- "lucide-react": "^0.400.0"
36
+ "lucide-react": "^0.400.0",
37
+ "vitest": "^4.1.5"
39
38
  },
40
39
  "license": "MIT",
41
40
  "keywords": [
@@ -47,6 +46,7 @@
47
46
  "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
48
47
  "dev": "tsup --watch",
49
48
  "typecheck": "tsc --noEmit",
50
- "clean": "rm -rf dist"
49
+ "clean": "rm -rf dist",
50
+ "test": "vitest run"
51
51
  }
52
52
  }
package/dist/seo.d.ts DELETED
@@ -1,16 +0,0 @@
1
- import type { BlogPost } from './types';
2
- /** Absolute canonical URL for a post. */
3
- export declare function postUrl(basePath: string, slug: string): string;
4
- /** schema.org BlogPosting + BreadcrumbList for an article page. */
5
- export declare function articleJsonLd(post: BlogPost, opts: {
6
- basePath: string;
7
- siteName: string;
8
- }): Array<Record<string, unknown>>;
9
- /** schema.org Blog + post list for the listing page. */
10
- export declare function blogJsonLd(posts: BlogPost[], opts: {
11
- basePath: string;
12
- siteName: string;
13
- title: string;
14
- }): Array<Record<string, unknown>>;
15
- export { useSeo } from '@fayz-ai/core';
16
- //# sourceMappingURL=seo.d.ts.map
package/dist/seo.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../src/seo.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAQvC,yCAAyC;AACzC,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,mEAAmE;AACnE,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAuB1H;AAED,wDAAwD;AACxD,wBAAgB,UAAU,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAkBzI;AAGD,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA"}
@@ -1,20 +0,0 @@
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
- }
@@ -1,54 +0,0 @@
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
- }
@@ -1,44 +0,0 @@
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
- }
@@ -1,108 +0,0 @@
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 DELETED
@@ -1,55 +0,0 @@
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
- /** Tenant whose PUBLISHED posts the public (anon) site reads from Supabase.
18
- * Required for the Supabase provider on an anon website (mirrors how the
19
- * public booking plugin takes an explicit tenantId). Without it the safe
20
- * resolver falls back to the mock/seed provider. */
21
- tenantId?: string
22
- /** Section eyebrow / labels for the default list screen. */
23
- labels?: {
24
- /** Title shown at the top of the /blog list screen. */
25
- listTitle?: string
26
- /** Subtitle under the list title. */
27
- listSubtitle?: string
28
- /** "Back to blog" link label on the detail screen. */
29
- backToList?: string
30
- }
31
- scope?: PluginScope
32
- verticalId?: VerticalId
33
- }
34
-
35
- export interface ResolvedBlogConfig {
36
- basePath: string
37
- siteName: string
38
- labels: {
39
- listTitle: string
40
- listSubtitle: string
41
- backToList: string
42
- }
43
- }
44
-
45
- export function resolveConfig(options?: BlogPluginOptions): ResolvedBlogConfig {
46
- return {
47
- basePath: options?.basePath ?? '/blog',
48
- siteName: options?.siteName ?? '',
49
- labels: {
50
- listTitle: options?.labels?.listTitle ?? 'Blog',
51
- listSubtitle: options?.labels?.listSubtitle ?? '',
52
- backToList: options?.labels?.backToList ?? '← Voltar ao blog',
53
- },
54
- }
55
- }
package/src/context.tsx DELETED
@@ -1,23 +0,0 @@
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
- }
package/src/data/index.ts DELETED
@@ -1,4 +0,0 @@
1
- export type { BlogDataProvider } from './types'
2
- export { createMockBlogProvider } from './mock'
3
- export type { BlogSeed, BlogSeedPost, MockBlogProviderOptions } from './mock'
4
- export { createSupabaseBlogProvider } from './supabase'
package/src/data/mock.ts DELETED
@@ -1,71 +0,0 @@
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
- }