Skip to content

Commit c190572

Browse files
committed
Add template type system, remove content-type-specific code from core
Template Type System: - beacon_template_types table (V012): id, site (null=global), name, slug, field_definitions (JSONB), json_ld_mapping (JSONB), meta_tag_mapping (JSONB) - template_type_id and fields (JSONB) columns on pages and snapshots - TemplateType schema with field_definitions validation - TemplateType CRUD in Content API (create, update, delete, list, get, get_by_slug) - FieldValidator: validates page fields against template type definitions - JsonLdResolver: resolves {field} references in JSON-LD mapping templates - MetaTagResolver: resolves {field} references in meta tag mapping templates Removed from core (content-type-specific → now handled by template types): - page_type, faq_items, author_id fields from Page/PageSnapshot schemas - beacon_authors table and Author schema - article_schema/3, faq_page_schema/1, person_schema/2 from JsonLd - render_faq_section/1 from Layouts - resolve_author/2 from Loader - Author CRUD from Content API Integration: - JsonLd.build/3 now resolves template type json_ld_mapping via JsonLdResolver - Meta tag cascade includes template type meta_tag_mapping via MetaTagResolver - DataSource passes page fields to snippet interpolation context - Metrics replaced author/faq/article counts with pages_with_template_type - Page version bumped to 6 with snapshot handlers
1 parent a2bbaf3 commit c190572

18 files changed

Lines changed: 1056 additions & 277 deletions

File tree

lib/beacon/content.ex

Lines changed: 120 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ defmodule Beacon.Content do
4343
alias Beacon.Content.LayoutEvent
4444
alias Beacon.Content.LayoutSnapshot
4545
alias Beacon.Content.Page
46-
alias Beacon.Content.Author
4746
alias Beacon.Content.InternalLink
4847
alias Beacon.Content.PageEvent
4948
alias Beacon.Content.Redirect
@@ -793,11 +792,11 @@ defmodule Beacon.Content do
793792
"extra" => page.extra,
794793
"ast" => ast,
795794
"date_modified" => page.date_modified,
796-
"faq_items" => page.faq_items || [],
797-
"author_id" => page.author_id
795+
"template_type_id" => page.template_type_id,
796+
"fields" => page.fields || %{}
798797
}
799798

800-
fields = [:site, :schema_version, :event_id, :page, :page_id, :path, :title, :template, :format, :extra, :ast, :date_modified, :faq_items, :author_id]
799+
fields = [:site, :schema_version, :event_id, :page, :page_id, :path, :title, :template, :format, :extra, :ast, :date_modified, :template_type_id, :fields]
801800

802801
result =
803802
%PageSnapshot{}
@@ -1298,6 +1297,19 @@ defmodule Beacon.Content do
12981297
|> maybe_add_leading_slash()
12991298
end
13001299

1300+
defp extract_page_snapshot(%{schema_version: 6, page: %Page{} = page, ast: ast}) do
1301+
page = maybe_add_leading_slash(page)
1302+
case unwrap_ast(ast) do
1303+
nodes when is_list(nodes) -> %{page | ast: nodes}
1304+
_ -> page
1305+
end
1306+
end
1307+
1308+
defp extract_page_snapshot(%{schema_version: 6, page: %Page{} = page}) do
1309+
page
1310+
|> maybe_add_leading_slash()
1311+
end
1312+
13011313
defp extract_page_snapshot(_snapshot), do: nil
13021314

13031315
defp maybe_add_leading_slash(%{path: <<"/", _rest::binary>>} = page), do: page
@@ -4383,13 +4395,30 @@ defmodule Beacon.Content do
43834395
case insert_published_page(page) do
43844396
{:ok, page} ->
43854397
:ok = Beacon.PubSub.page_published(page)
4398+
# Async: extract internal links from the published page
4399+
maybe_rebuild_links_async(page)
43864400
{:reply, {:ok, page}, config}
43874401

43884402
error ->
43894403
{:reply, error, config}
43904404
end
43914405
end
43924406

4407+
defp maybe_rebuild_links_async(page) do
4408+
Task.start(fn ->
4409+
try do
4410+
template = Beacon.Lifecycle.Template.load_template(page)
4411+
ast = Beacon.Template.Parser.parse(template)
4412+
component_registry = build_component_registry_for_ast(page.site)
4413+
expanded = Beacon.Template.ComponentExpander.expand(ast, component_registry)
4414+
html = Beacon.Client.LiveViewCompiler.render_to_string(expanded, %{})
4415+
rebuild_links_for_page(page.site, page.id, html)
4416+
rescue
4417+
_ -> :ok
4418+
end
4419+
end)
4420+
end
4421+
43934422
@doc false
43944423
def handle_call({:unpublish_page, page}, _from, config) do
43954424
case insert_unpublished_page(page) do
@@ -4846,53 +4875,65 @@ defmodule Beacon.Content do
48464875
end
48474876

48484877
# ---------------------------------------------------------------------------
4849-
# Authors
4878+
# Template Types
48504879
# ---------------------------------------------------------------------------
48514880

4852-
@doc "Creates an author."
4853-
@doc type: :authors
4854-
@spec create_author(map()) :: {:ok, Author.t()} | {:error, Ecto.Changeset.t()}
4855-
def create_author(attrs) do
4856-
attrs = attrs |> Beacon.Types.Attrs.ensure_string_keys()
4857-
site = Beacon.Types.Attrs.get_site(attrs)
4858-
%Author{} |> Author.changeset(attrs) |> repo(site).insert()
4881+
alias Beacon.Content.TemplateType
4882+
4883+
@doc "Creates a template type. Pass `site` for site-specific, or set `site: nil` in attrs for global."
4884+
@doc type: :template_types
4885+
@spec create_template_type(Site.t(), map()) :: {:ok, TemplateType.t()} | {:error, Ecto.Changeset.t()}
4886+
def create_template_type(site, attrs) when is_atom(site) and is_map(attrs) do
4887+
%TemplateType{} |> TemplateType.changeset(attrs) |> repo(site).insert()
48594888
end
48604889

4861-
@doc "Updates an author."
4862-
@doc type: :authors
4863-
@spec update_author(Author.t(), map()) :: {:ok, Author.t()} | {:error, Ecto.Changeset.t()}
4864-
def update_author(%Author{} = author, attrs) do
4865-
author |> Author.changeset(attrs) |> repo(author).update()
4890+
@doc "Updates a template type."
4891+
@doc type: :template_types
4892+
@spec update_template_type(TemplateType.t(), map()) :: {:ok, TemplateType.t()} | {:error, Ecto.Changeset.t()}
4893+
def update_template_type(%TemplateType{} = tt, attrs) do
4894+
tt |> TemplateType.changeset(attrs) |> repo(tt).update()
48664895
end
48674896

4868-
@doc "Deletes an author."
4869-
@doc type: :authors
4870-
@spec delete_author(Author.t()) :: {:ok, Author.t()} | {:error, Ecto.Changeset.t()}
4871-
def delete_author(%Author{} = author) do
4872-
repo(author).delete(author)
4897+
@doc "Deletes a template type."
4898+
@doc type: :template_types
4899+
@spec delete_template_type(TemplateType.t()) :: {:ok, TemplateType.t()} | {:error, Ecto.Changeset.t()}
4900+
def delete_template_type(%TemplateType{} = tt) do
4901+
repo(tt).delete(tt)
48734902
end
48744903

4875-
@doc "Lists all authors for a site."
4876-
@doc type: :authors
4877-
@spec list_authors(Site.t(), keyword()) :: [Author.t()]
4878-
def list_authors(site, opts \\ []) when is_atom(site) do
4879-
per_page = Keyword.get(opts, :per_page, 100)
4880-
query = from(a in Author, where: a.site == ^site, order_by: [asc: a.name])
4881-
query = if per_page == :infinity, do: query, else: limit(query, ^per_page)
4882-
repo(site).all(query)
4904+
@doc "Lists template types available to a site (includes global types where site is nil)."
4905+
@doc type: :template_types
4906+
@spec list_template_types(Site.t(), keyword()) :: [TemplateType.t()]
4907+
def list_template_types(site, opts \\ []) when is_atom(site) do
4908+
from(tt in TemplateType,
4909+
where: tt.site == ^site or is_nil(tt.site),
4910+
order_by: [asc: tt.name]
4911+
)
4912+
|> repo(site).all()
48834913
end
48844914

4885-
@doc "Gets an author by ID."
4886-
@doc type: :authors
4887-
@spec get_author(Site.t(), String.t()) :: Author.t() | nil
4888-
def get_author(site, id) when is_atom(site) do
4889-
repo(site).get_by(Author, site: site, id: id)
4915+
@doc "Gets a template type by ID."
4916+
@doc type: :template_types
4917+
@spec get_template_type(Site.t(), String.t()) :: TemplateType.t() | nil
4918+
def get_template_type(site, id) when is_atom(site) do
4919+
repo(site).get(TemplateType, id)
48904920
end
48914921

4892-
@doc "Returns a changeset for tracking author changes."
4893-
@doc type: :authors
4894-
def change_author(%Author{} = author, attrs \\ %{}) do
4895-
Author.changeset(author, attrs)
4922+
@doc "Gets a template type by slug (checks site-specific first, then global)."
4923+
@doc type: :template_types
4924+
@spec get_template_type_by_slug(Site.t(), String.t()) :: TemplateType.t() | nil
4925+
def get_template_type_by_slug(site, slug) when is_atom(site) do
4926+
# Prefer site-specific over global
4927+
case repo(site).get_by(TemplateType, site: site, slug: slug) do
4928+
nil -> repo(site).get_by(TemplateType, site: nil, slug: slug)
4929+
tt -> tt
4930+
end
4931+
end
4932+
4933+
@doc "Returns a changeset for tracking template type changes."
4934+
@doc type: :template_types
4935+
def change_template_type(%TemplateType{} = tt, attrs \\ %{}) do
4936+
TemplateType.changeset(tt, attrs)
48964937
end
48974938

48984939
# ---------------------------------------------------------------------------
@@ -4966,6 +5007,40 @@ defmodule Beacon.Content do
49665007
|> repo(site).all()
49675008
end
49685009

5010+
@doc "Returns per-page inbound and outbound link counts."
5011+
@doc type: :links
5012+
@spec list_link_stats(Site.t()) :: [%{page_id: String.t(), path: String.t(), inbound: integer(), outbound: integer()}]
5013+
def list_link_stats(site) when is_atom(site) do
5014+
inbound_q =
5015+
from(l in InternalLink,
5016+
where: l.site == ^site and not is_nil(l.target_page_id),
5017+
group_by: l.target_page_id,
5018+
select: %{page_id: l.target_page_id, count: count(l.id)}
5019+
)
5020+
5021+
outbound_q =
5022+
from(l in InternalLink,
5023+
where: l.site == ^site,
5024+
group_by: l.source_page_id,
5025+
select: %{page_id: l.source_page_id, count: count(l.id)}
5026+
)
5027+
5028+
inbound_map = repo(site).all(inbound_q) |> Map.new(&{&1.page_id, &1.count})
5029+
outbound_map = repo(site).all(outbound_q) |> Map.new(&{&1.page_id, &1.count})
5030+
5031+
list_published_pages(site, per_page: :infinity)
5032+
|> Enum.map(fn page ->
5033+
%{
5034+
page_id: page.id,
5035+
path: page.path,
5036+
title: page.title,
5037+
inbound: Map.get(inbound_map, page.id, 0),
5038+
outbound: Map.get(outbound_map, page.id, 0)
5039+
}
5040+
end)
5041+
|> Enum.sort_by(& &1.inbound)
5042+
end
5043+
49695044
# ---------------------------------------------------------------------------
49705045
# SEO Measurement
49715046
# ---------------------------------------------------------------------------
@@ -5016,9 +5091,12 @@ defmodule Beacon.Content do
50165091
"""
50175092
@doc type: :redirects
50185093
@spec create_redirect(map()) :: {:ok, Redirect.t()} | {:error, Ecto.Changeset.t()}
5019-
def create_redirect(attrs) do
5020-
attrs = attrs |> Beacon.Types.Attrs.ensure_string_keys()
5021-
site = Beacon.Types.Attrs.get_site(attrs)
5094+
def create_redirect(attrs) when is_map(attrs) do
5095+
attrs = Map.new(attrs, fn
5096+
{key, val} when is_binary(key) -> {key, val}
5097+
{key, val} -> {Atom.to_string(key), val}
5098+
end)
5099+
{:ok, site} = Beacon.Types.Site.cast(attrs["site"])
50225100

50235101
# Flatten chains: if destination is another redirect's source, point to final destination
50245102
attrs = flatten_redirect_chain(site, attrs)

lib/beacon/content/author.ex

Lines changed: 0 additions & 34 deletions
This file was deleted.

lib/beacon/content/page.ex

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ defmodule Beacon.Content.Page do
2525
alias Beacon.Content
2626
alias Beacon.Content.Page.Helper
2727

28-
@version 5
28+
@version 6
2929

3030
@type t :: %__MODULE__{}
3131

@@ -44,10 +44,9 @@ defmodule Beacon.Content.Page do
4444
field :og_description, :string
4545
field :og_image, :string
4646
field :twitter_card, :string
47-
field :page_type, :string, default: "website"
4847
field :date_modified, :utc_datetime_usec
49-
field :faq_items, {:array, :map}, default: []
50-
field :author_id, Ecto.UUID
48+
field :template_type_id, Ecto.UUID
49+
field :fields, :map, default: %{}
5150
field :order, :integer, default: 1
5251
field :format, Beacon.Types.Atom, default: :heex
5352
field :extra, :map, default: %{}
@@ -91,10 +90,9 @@ defmodule Beacon.Content.Page do
9190
:og_description,
9291
:og_image,
9392
:twitter_card,
94-
:page_type,
9593
:date_modified,
96-
:faq_items,
97-
:author_id,
94+
:template_type_id,
95+
:fields,
9896
:order,
9997
:layout_id,
10098
:format,
@@ -142,10 +140,9 @@ defmodule Beacon.Content.Page do
142140
:og_description,
143141
:og_image,
144142
:twitter_card,
145-
:page_type,
146143
:date_modified,
147-
:faq_items,
148-
:author_id,
144+
:template_type_id,
145+
:fields,
149146
:format
150147
])
151148
|> cast(attrs, [:path], empty_values: [])

lib/beacon/content/page_snapshot.ex

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,9 @@ defmodule Beacon.Content.PageSnapshot do
3737
field :og_description, :string
3838
field :og_image, :string
3939
field :twitter_card, :string
40-
field :page_type, :string, default: "website"
4140
field :date_modified, :utc_datetime_usec
42-
field :faq_items, {:array, :map}, default: []
43-
field :author_id, Ecto.UUID
41+
field :template_type_id, Ecto.UUID
42+
field :fields, :map, default: %{}
4443
belongs_to :event, Beacon.Content.PageEvent
4544
timestamps updated_at: false
4645
end

0 commit comments

Comments
 (0)