JSON-LD structured data (schema.org)
Emit <script type="application/ld+json"> blocks with schema.org metadata in
every rendered note page so search engines and AI crawlers understand the
content. The decision of what to emit is made on the backend per note,
mirroring the mermaid "backend-decided per-note widget" pattern
(docs/dev/mermaid.md). No JavaScript, no new endpoint — static JSON injected
into the page <head>.
Status: implemented — what shipped vs. this plan
The feature is implemented. Two deliberate divergences from the plan below:
- Serialization: quicktemplate, not
json.Marshal. The JSON is assembled in
internal/defaulttemplate/jsonld.htmlusing{%q= %}(JSON-quoted, XSS-safe
against</script>) — far cheaper thanjson.Marshalon the per-page hot
path. Go (internal/defaulttemplate/jsonld.go) makes the data decisions
(JSONLDType,ShouldEmitJSONLD,JSONLDBreadcrumb, image/date selection,
DeriveSiteName,JSONLDLogo); the template assembles the JSON with a
leading-comma technique so optional fields stay valid. - Site name & logo sources.
SiteConfighas no site-name/logo field, so the
site name is derived fromsite_title_template(e.g.My Blogfrom
%s | My Blog, host fallback) and theOrganizationlogo is the first image
of the header note (_header.md/ glob-matched header section).FAQPage
(Story 7) was not implemented.
The @type decision shipped simplified: schema_type override → ProfilePage
for type: profile/person → WebPage for home pages → BlogPosting default.
The rest of this document is the original plan, kept for context.
Why backend-decided
The renderer already parses every note at load time (title, dates, lang,
description, first image, headings, code languages). It knows the note's
metadata before the page is sent. So, exactly like the conditional widget
loader, it can decide the schema.org @type and assemble the JSON-LD payload
server-side and put it straight into the initial HTML. No client logic.
The chosen @type is a small server-side decision, parallel to
note.HasCodeLanguage("mermaid") driving the mermaid script: a single function
inspects the note (frontmatter type:, path, magazine flag) and returns the
schema.org type.
What @type per note
The page kind is decided by Ctx.JSONLDType() (new), in priority order:
| Condition | schema.org @type |
|---|---|
frontmatter schema_type: set (explicit override) |
that value verbatim |
frontmatter type: profile or type: person |
ProfilePage + nested Person |
frontmatter faq: true or content has a FAQ structure (see Story 5) |
FAQPage |
note is a magazine index (ContentRefs() contains Magazine) |
CollectionPage |
note is the site root / home page (IsHomePage() / permalink /) |
WebSite (+ Organization publisher) |
| default (a normal note) | BlogPosting |
BlogPosting is preferred over Article because trip2g publishes Obsidian
notes as posts; BlogPosting is a strict subclass of Article and is what
Google's Article rich-result docs accept. schema_type: is the escape hatch
for authors who want Article, NewsArticle, TechArticle, HowTo, etc.
In addition to the page-type block, every indexable page also emits a
BreadcrumbList (derived from the permalink path) and, on every page, a single
site-level WebSite + Organization graph node. To keep it simple these are
combined into one @graph array in a single <script> (see Story 4).
Data mapping: NoteView/Ctx → JSON-LD
The render context already exposes most of what we need. Mapping for the main
BlogPosting node:
| JSON-LD property | Source (today) | File |
|---|---|---|
headline |
ctx.Title (= Note.Title()) |
template.go:47, note.go:41 |
description |
*ctx.MetaDescription (= Note.Description()) |
template.go:53, note.go:120 |
url / mainEntityOfPage |
Notes.ResolveFullURL(note, publicURL) or og:url from OGTags |
note.go:1022, endpoint.go:390 |
inLanguage |
Note.Lang() / ctx.HTMLLang |
note.go:199, template.go:61 |
datePublished |
Note.CreatedAt() (RFC3339) |
note.go:100, populated note_created_at.go |
image |
Note.FirstImageURL() (resolved asset URL) |
note.go:251 |
dateModified |
MISSING — needs new field (Story 1) | — |
author (Person) |
MISSING — needs new field (Story 1) | — |
keywords (tags) |
MISSING — needs new field (Story 1) | — |
publisher (Organization) |
site config: name + logo (env.SiteConfig) |
endpoint.go:544 |
wordCount |
derivable from ReadingTime or content (optional) |
note.go:210 |
Important: og:url already accounts for custom-domain routes
(ogURLForNote, endpoint.go:419). JSON-LD url/mainEntityOfPage MUST use
the same value as og:url so they never disagree. Pass that URL into the
JSON-LD builder rather than recomputing it. On a custom domain the canonical URL
in JSON-LD will then be the domain URL, matching OG.
Fields missing from NoteView (must be added)
NoteView (internal/model/note.go:156) today has Title, CreatedAt,
Description *string, FirstImage *string, Permalink, Lang. It has no
author, no modified/updated date, no tags. Confirmed: ExtractMetaData()
(note.go:548) extracts description, reading time, headings, charts, code
languages, TOC, layout, MCP, RSS, routes, lang — but not author/tags/updated.
CreatedAt comes from the DB and is overridden by frontmatter
created_at/created_on (note_created_at.go).
So we add (Story 1): Author string, UpdatedAt time.Time, Tags []string,
parsed from frontmatter author, updated/modified/updated_at, and
tags/keywords.
Where and how to inject (quicktemplate)
Inject in Render() <head> in internal/defaulttemplate/views.html, right
after the existing OG tags block (after line 44, before the
window.__trip2g_settings script). Gate it behind a guard that suppresses
JSON-LD for non-indexable pages (Story 6).
Escaping — verified
quicktemplate output tags behave as follows (verified against generated
views.html.go and library usage):
{%s= x %}→qw.N().S(x)— raw, no escaping (would corrupt JSON; not used here).{%q= x %}→qw.N().Q(x)— JSON/JS-quoted string (used for__trip2g_settings).{%z= b []byte %}→qw.N().Z(b)— writes raw bytes, no escaping. This is
exactly how the existing form spec is embedded in a<script>:
views.html:306-308→qw.N().Z(specJSON)(views.html.go:1372).- There is no
{%j %}tag in this quicktemplate version; the codebase always
uses explicitjson.Marshal+{%z= %}/{%s= string(b) %}.
Recommended approach: build the payload in Go (ctx.JSONLD() []byte via
json.Marshal) and emit with {%z= %}, identical to FormSpecJSON.
</script> safety: Go's encoding/json.Marshal escapes <, >, and & to
<, >, & by default (HTML-safe), so a </script> inside
any string value cannot break out of the <script> element. Do NOT disable this
with SetEscapeHTML(false). (Note: FormSpecJSON in templateviews/note.go:351
uses enc.SetEscapeHTML(true) — keep the same for JSON-LD; the template.go
FormSpecJSON path uses plain json.Marshal, which is also HTML-safe.)
Template snippet (after line 44 in views.html):
{% code jsonld := ctx.JSONLD() %}
{% if len(jsonld) > 0 %}
<script type="application/ld+json">{%z= jsonld %}</script>
{% endif %}
After editing views.html you MUST run
go generate ./internal/defaulttemplate/... and commit the regenerated
views.html.go together with the template (CLAUDE.md, "Critical: Default
Template Pipeline").
i18n / locale
JSON-LD is mostly machine data (URLs, dates, names) and needs little
translation. The two locale-sensitive points:
inLanguage— set fromNote.Lang()(BCP-47). If empty, omit the property
rather than guessing.Organization/publishername— comes from site config, already authored by
the site owner; not template-translated.
If any human-readable label is ever needed (none in the base plan), use
ctx.T(...) (i18n.go:54) so it follows UILang, consistent with the rest of
the template. No new TOML keys are required for the base implementation.
Multilang & routes
inLanguage= the note's ownLang.- For a note in a
LangGroup, the JSON-LD MAY add the language alternatives.
The page already emits<link rel="alternate" hreflang=...>from
ctx.HrefLangs(views.html:14-16, built bybuildHrefLangs,
endpoint.go:347). For JSON-LD keep it minimal: setinLanguageonly;
hreflang already covers alternates for search engines. (Optional later: add a
workTranslation/translationOfWorkgraph — out of scope for v1.) url/mainEntityOfPagemust equalog:url, which is already route/custom-
domain aware (ogURLForNote,endpoint.go:419; seedocs/dev/routes.md
"OG теги"). Reusectx.OGTags["og:url"]as the canonical URL inside JSON-LD so
both always agree, including on custom domains.
Edge cases
- Missing author: omit the
authorproperty entirely (do not emit an empty
Person). Google treatsBlogPostingwithout author as valid-but-weaker. - Missing dates:
CreatedAtis always populated (DB fallback), so
datePublishedis always available.dateModifiedis omitted if the new
UpdatedAtis zero. - Missing image: omit
imageifFirstImageURL()is "". - Non-public notes (paywall / sign-in): these pages already set
MetaRobots = "noindex, nofollow"(endpoint.go:119, 132) and the body is the
paywall/sign-in widget, not the article. Do not emit article JSON-LD for
them — there is no content to describe and it would mislead crawlers. Gate on:
ctx.PaywallError == nil && ctx.SigninWallError == nil && ctx.OnboardingMode == false && ctx.NotFoundMode == false && ctx.UnsupportedFileExt == "" && ctx.MetaRobots does not contain "noindex". - Free vs paywalled, but rendered: a
free: truenote renders fully — emit
JSON-LD. A note behind a paywall never reachesSelfContent, so the gate above
already excludes it. - Draft: trip2g has no first-class
draftfield today (search found none);
unpublished content simply isn't synced. If adraft: trueconvention is later
added, treat it like noindex. Not in scope for v1. - System notes (
_header,_footer,_sidebar, layout sections): these are
embedded, not rendered as their own page throughSelfContent, so they won't
carry their own JSON-LD. No special handling needed. - 404 / unsupported file / onboarding: excluded by the gate (no article
content). - Federation: federated/aggregated notes (MCP federation,
MCPFederationKB*
fields) are an API concern, not HTML page rendering — they do not go through
defaulttemplate.Render, so JSON-LD does not touch them.
Implementation plan
Story 1: NoteView metadata fields
Add to internal/model/note.go (NoteView struct, near Description):
type NoteView struct {
// ... existing fields ...
Author string // frontmatter "author"
UpdatedAt time.Time // frontmatter "updated"/"modified"/"updated_at"; zero if unset
Tags []string // frontmatter "tags" or "keywords"
}
Extract in ExtractMetaData() (call a new extractJSONLDFields() alongside the
existing extractors):
func (n *NoteView) extractJSONLDFields(loc *time.Location) {
if a, ok := n.RawMeta["author"].(string); ok {
n.Author = strings.TrimSpace(a)
}
for _, key := range []string{"updated_at", "updated", "modified"} {
if v, ok := n.RawMeta[key].(string); ok {
if t, parsed := parseDate(v, loc); parsed {
n.UpdatedAt = t
break
}
}
}
n.Tags = extractTagList(n.RawMeta["tags"], n.RawMeta["keywords"])
}
Reuse the existing parseDate helper (note_created_at.go:43). loc is already
threaded into ExtractCreatedAt(time.UTC) from the loader
(mdloader/loader.go:224) — pass the same loc.
TDD: extend the model tests (pattern: note_codelang_test.go,
table-driven, testify/require). Cases: author present/absent; each date alias;
tags as []interface{}, []string, single string, absent; invalid date adds no
field (and optionally a warning).
Story 2: templateviews accessors
Add narrow accessors to internal/templateviews/note.go (do NOT make templates
Unwrap() the model — same rule as mermaid, mermaid.md):
func (n *Note) Author() string { return n.nv.Author }
func (n *Note) UpdatedAt() time.Time { return n.nv.UpdatedAt }
func (n *Note) Tags() []string { return n.nv.Tags }
CreatedAt(), Title(), Description(), Lang(), FirstImageURL(),
Permalink() already exist.
TDD: small accessor tests in note_test.go.
Story 3: JSON-LD builder
Add a jsonld.go file in internal/defaulttemplate/ with the payload structs
and the type-decision function. Build typed Go structs (not map) marshaled with
json.Marshal so field order and omitempty are controlled:
type ldNode struct {
Context string `json:"@context,omitempty"`
Type string `json:"@type"`
Headline string `json:"headline,omitempty"`
Description string `json:"description,omitempty"`
URL string `json:"url,omitempty"`
MainEntityOfPage string `json:"mainEntityOfPage,omitempty"`
InLanguage string `json:"inLanguage,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
DateModified string `json:"dateModified,omitempty"`
Image string `json:"image,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Author *ldPerson `json:"author,omitempty"`
Publisher *ldOrg `json:"publisher,omitempty"`
}
ctx.JSONLDType() implements the decision table above. ctx.JSONLD() []byte
assembles a @graph with: the page node (BlogPosting/ProfilePage/...), a
BreadcrumbList, and the site WebSite+Organization. Returns nil when the
gate (Story 6) says the page is non-indexable.
Use ctx.OGTags["og:url"] for url/mainEntityOfPage (route/domain aware).
Dates via .Format(time.RFC3339). Keep SetEscapeHTML enabled (default).
TDD: table-driven tests on JSONLD() output — unmarshal the bytes back and
assert @type, presence/absence of optional fields per input, and that a string
containing </script> is escaped (assert raw bytes contain </script).
Magazine/home/profile/faq type selection. Tests live next to the builder
(jsonld_test.go) and need a Ctx with a wrapped *model.NoteView — follow
magazine_test.go for Ctx construction.
Story 4: BreadcrumbList + WebSite/Organization graph
Inside JSONLD():
BreadcrumbList: splitnote.Permalinkon/, buildListItems withname
(title-cased segment, or resolved note title viactx.Notes.ByPermalinkwhen
available) anditem(full URL = publicURL + cumulative path). Home is item 1.WebSite:name+urlfrom site config; optionalpotentialAction
SearchAction pointing at the site search if desired (optional).Organization(publisher):name+logofrom site config. Source the site
name/logo fromenv.SiteConfig(ctx)(already used inbuildDefaultTemplateCtx,
endpoint.go:544) — thread the needed values ontoCtxin Story 5 rather than
calling env from the template.
TDD: assert breadcrumb item count and URLs for a nested permalink
(/a/b/c), and that the graph always includes WebSite+Organization.
Story 5: wire data into Ctx
Ctx (internal/defaulttemplate/template.go:43) needs the site-level values not
currently present: site name, site logo URL, and the canonical public base URL.
Add fields:
SiteName string
SiteLogoURL string
PublicURL string // base, e.g. https://example.com
Populate them in buildDefaultTemplateCtx
(internal/case/rendernotepage/endpoint.go:471) from
env.SiteConfig(context.Background()) and env.PublicURL() (both already used
in that function). No new env methods required — PublicURL() and SiteConfig()
are already on the Env interface used here.
TDD: a focused test that buildDefaultTemplateCtx copies these through
(or assert via the existing endpoint/resolve test harness,
rendernotepage/resolve_test.go).
Story 6: template injection + indexability gate
Add ctx.ShouldEmitJSONLD() bool to template.go:
func (ctx *Ctx) ShouldEmitJSONLD() bool {
if ctx.Note == nil { return false } // magazine root handled separately if desired
if ctx.PaywallError != nil || ctx.SigninWallError != nil { return false }
if ctx.OnboardingMode || ctx.NotFoundMode || ctx.UnsupportedFileExt != "" { return false }
if strings.Contains(ctx.MetaRobots, "noindex") { return false }
return true
}
JSONLD() returns nil when !ShouldEmitJSONLD().
Edit internal/defaulttemplate/views.html Render() <head>, after the OG
block (after line 44):
{% code jsonld := ctx.JSONLD() %}
{% if len(jsonld) > 0 %}
<script type="application/ld+json">{%z= jsonld %}</script>
{% endif %}
Run go generate ./internal/defaulttemplate/... and commit views.html.go.
TDD: render a full page via the existing template test path and assert the
<script type="application/ld+json"> is present for a normal note and absent for
paywall/404/onboarding. An e2e check can be added to e2e/vault.spec.js
(pattern: the mermaid "loads on /mermaid but not elsewhere" test) asserting the
ld+json block exists on a content page and parses as JSON.
Story 7 (optional): FAQPage detection
Detect a FAQ structure for FAQPage @type:
- Simplest: frontmatter
faq: true→ treat all H2/H3 + following content as
Q/A pairs usingNote.Headings+PartialRenderer().Sections(level)
(note.go:213,note.go:101). BuildmainEntity: [{ @type: Question, name, acceptedAnswer: { @type: Answer, text } }]. - This is additive; default behavior (BlogPosting) is unaffected when
faqis
unset.
TDD: a note with faq: true and two H2 sections produces two Question
entries with the section text as the answer.
Build commands (after implementing)
| Command | Why |
|---|---|
go generate ./internal/defaulttemplate/... |
Regenerate views.html.go after editing views.html (commit both) |
go test ./internal/model/... ./internal/templateviews/... ./internal/defaulttemplate/... |
Run model, accessor, and builder tests |
npm run test:e2e |
If an e2e assertion is added |
No CSS, no JS bundle, no qtc widget — JSON-LD is pure server-rendered markup,
so none of the asset build steps (defaulttemplate-css, toc, mermaid,
build) apply.
Files to touch
| File | Change |
|---|---|
internal/model/note.go |
Author/UpdatedAt/Tags fields; call extractJSONLDFields() |
internal/model/note_created_at.go |
reuse parseDate (no change, or move helper) |
internal/model/jsonld_test.go (new) |
field-extraction tests |
internal/templateviews/note.go |
Author()/UpdatedAt()/Tags() accessors |
internal/defaulttemplate/jsonld.go (new) |
payload structs, JSONLDType(), JSONLD(), ShouldEmitJSONLD() |
internal/defaulttemplate/jsonld_test.go (new) |
builder tests |
internal/defaulttemplate/template.go |
SiteName/SiteLogoURL/PublicURL Ctx fields |
internal/defaulttemplate/views.html |
inject <script type="application/ld+json"> in <head> |
internal/defaulttemplate/views.html.go |
regenerated (commit) |
internal/case/rendernotepage/endpoint.go |
populate new Ctx fields in buildDefaultTemplateCtx |
e2e/vault.spec.js (optional) |
assert ld+json present/parses |
See also
docs/dev/mermaid.md— the per-note backend-decided emission pattern this mirrorsdocs/dev/default_template.md— Ctx fields,<head>rendering, i18ndocs/dev/multilang.md—Lang,LangGroup, hreflangdocs/dev/routes.md— custom-domainog:url(reused for JSON-LDurl)