Guide
External sources
One unified system feeds templates with data from outside the content tree:
local files, remote HTTP APIs, SQL databases and CMS databases (WordPress,
Drupal, Movable Type) — all behind a single registry, one cache, one secrets
rule and one error model. The legacy .Data namespace is untouched. A working
project lives in examples/external-sources/.
1External Source → Connector → Parser/Adapter → Normalizer → Cache
2 → Unified Data Registry → Generator → Templates
Configuration
1external_sources:
2 enabled: true
3
4 cache_dir: .ssg-cache/external-sources
5 offline: false # serve HTTP sources from the disk cache only
6 refresh: false # ignore fresh cache entries, re-fetch
7 stale_if_error: true # fall back to an expired copy when a fetch fails
8 fail_on_cache_miss: true # offline + no cached copy = build failure
9 max_concurrent_sources: 4
10 allowed_hosts: [] # optional HTTP allowlist: "api.example.com", "*.example.com", "127.0.0.1:8787"
11
12 defaults:
13 timeout: 10s
14 cache_ttl: 1h
15 stale_ttl: 24h
16 retries: 2
17 retry_backoff: 500ms
18 max_response_size: 5MB
19 required: true
20 allow_http: false # applies to every source that does not override it
21 allow_private: false
22
23 sources:
24 local_catalog:
25 type: file
26 path: ./data/catalog.csv
27 products_api:
28 type: http
29 url: https://api.example.com/products
30 format: json
31 products_db:
32 type: sql
33 driver: postgres
34 dsn: "$PRODUCT_DB_DSN"
35 queries:
36 products:
37 sql: SELECT id, name, slug FROM products WHERE published = true
38 wordpress:
39 type: cms
40 adapter: wordpress
41 driver: mysql
42 dsn: "$WORDPRESS_DSN"
Source names must match [a-z][a-z0-9_-]*. Sources load in deterministic
(name-sorted) order, up to max_concurrent_sources at a time. A required
source's failure aborts the build; an optional one (required: false) warns
and is skipped. Every failure names the source, its type and the stage
(config, read, fetch, parse, transform, connect, query,
import) — and never contains credentials.
Secrets come only from the environment. Values written as "$NAME"
resolve from the environment at build time; literal secrets in dsn, auth
and jwt-style fields are rejected outright, and a referenced-but-unset
variable fails the build naming the variable (never a value).
Environment variables in values
url, headers and query expand environment references inline, so one
config serves every environment instead of being generated per environment:
1accommodations:
2 type: http
3 url: "$MY_API_BASE/api/accommodations" # or "${MY_API_BASE}/api/..."
4 headers:
5 Authorization: "Bearer $API_TOKEN"
1MY_API_BASE=https://api.example.com ssg … # production
2MY_API_BASE=http://127.0.0.1:8787 ssg … --wrangler-dir=api # local Worker
Rules:
$NAMEand${NAME}expand;$$is a literal$. A$not followed by a variable name ($5, a trailing$) stays literal.- A variable that is unset or empty counts as missing.
- Missing variable in a required source (the default) fails the build,
naming the variable. In an optional source (
required: false, ordefaults.required: false) the source is skipped with a warning — so a shared config can carry an env-driven source nobody else has to set up. dsnandauthsecret fields keep the stricter whole-value form (dsn: "$PRODUCT_DB_DSN"); a literal there is still rejected.
Local files (type: file)
Formats: YAML, JSON, TOML, CSV, XML — inferred from the extension or forced
with format:. Files are size-capped by max_response_size.
1rates:
2 type: file
3 path: ./data/rates.csv
4 csv:
5 header: true # rows become {column: value} maps (default)
6 delimiter: ","
XML maps to nested template-friendly maps: attributes become plain keys,
repeated elements collect into lists, text-only elements collapse to strings
and mixed content keeps its text under #text.
format: feed — read any syndication feed
SSG could always fetch a feed, but not understand one: Atom puts its entries
at .feed.entry, RSS at .rss.channel.item and JSON Feed at .items, with the
title, link and date in different places and dates in different encodings. A
template had to know which format was on the other end, and swapping a source
from RSS to Atom broke it even though the content was identical.
format: feed accepts Atom 1.0, RSS 2.0 or JSON Feed 1.1 and returns one
shape. The format is detected from the payload, not the declaration — a .xml
URL may be either, and a redirect can change what arrives.
1external_sources:
2 sources:
3 mddb:
4 type: http
5 url: https://mddb.tradik.com/feed.xml
6 format: feed
| Path | Contents |
|---|---|
.title, .home_page_url |
Feed metadata |
.items |
Every entry, in feed order |
<item>.title, .url, .id |
Identity |
<item>.summary, .content_html |
Body |
<item>.published, .updated |
Parsed time.Time — the date helpers and sorting work on them |
<item>.tags, .author |
Categories and byline |
Dates being real timestamps is what lets items from different feeds sort against
each other, which is what makes aggregating several sources possible at all — see
feeds: in CONFIGURATION.
format: changelog — a CHANGELOG.md as structured data
A CHANGELOG.md following the Keep a Changelog
convention is already the canonical record of what shipped. format: changelog
parses it into data, so a "What's New" panel reads from the same file you edit
at release time instead of a pre-build script or hand-maintained HTML that goes
stale. It must be set explicitly — a .md extension infers nothing.
1external_sources:
2 sources: # sources live under `sources:`, not directly here
3 changelog:
4 type: file
5 path: CHANGELOG.md
6 format: changelog
The parsed shape:
| Path | Contents |
|---|---|
.versions |
Every version block, in document order |
.latest |
The first released version (skips Unreleased) |
.unreleased |
The Unreleased block, when the file has one |
<version>.version / .date / .released |
"1.8.16", "2026-08-02", true |
<version>.sections |
Lowercased ### headings ⇒ added, changed, fixed, … |
<version>.entries |
Every bullet of that version, regardless of section |
Each entry is split so a template can wrap the parts in its own markup:
| Field | Contents |
|---|---|
title |
The leading <strong>bold</strong> run, rendered inline ("" when absent) |
html |
The rest of the entry, rendered inline |
full |
The whole entry rendered inline, when you want it in one piece |
marker |
The leading emoji, when the entry opens with one |
text |
The whole entry as raw Markdown |
<h3>What's New in v{{ .ExternalData.changelog.latest.version }}</h3>
<ul>
{{ range first 6 .ExternalData.changelog.latest.sections.added }}
<li><strong>{{ .title | safeHTML }}</strong> — {{ .html | safeHTML }}</li>
{{ end }}
</ul>
Headings parse in both ## [1.8.16] - 2026-08-02 and ## 1.8.16 - 2026-08-02
form, - and * bullets are equivalent, and a bullet wrapped over several lines
is joined back into one entry.
Remote HTTP (type: http)
1products_api:
2 type: http
3 url: https://api.example.com/products
4 format: json # json | csv | xml (yaml/toml also work)
5 headers:
6 Accept: application/json
7 query:
8 page: "1"
9 auth:
10 type: bearer # bearer | basic | header
11 token: "$API_TOKEN"
12 timeout: 10s
13 cache_ttl: 1h
14 stale_ttl: 24h
15 retries: 2
16 retry_backoff: 500ms
Security, always on:
- HTTPS required; plain
http://needsallow_http: true. - localhost and private/link-local IPs are refused at dial time, which
also defeats DNS rebinding — opt out with
allow_private: truefor self-hosted APIs. allow_httpandallow_privatecan be set per source or once underexternal_sources.defaults; a source may still override either.- Optional global
allowed_hostsallowlist (exact or*.wildcard). An entry without a port matches the host on any port; an entry with a port (127.0.0.1:8787,api.example.com:443) only matches that port, so a local-dev allowance does not widen to every port on the host. - Redirects are capped at 5 and every hop is re-validated.
- Responses are size-capped; clearly conflicting
Content-Types are rejected. - Error messages carry the URL without its query string.
Pagination (GO-062)
By default an HTTP source fetches exactly one response. Paginated APIs (WordPress REST returns 10 posts per page!) can opt in per source:
1posts_api:
2 type: http
3 url: https://api.example.com/posts
4 format: json # pagination aggregates pages into one JSON array
5 pagination:
6 mode: page # page | link
7 param: page # query parameter for mode=page (default: page)
8 start_page: 1 # first page number (default: 1)
9 per_page: 100 # page-size value; sent only when > 0
10 per_page_param: per_page # page-size parameter name (default: per_page)
11 max_pages: 10 # hard guard, default 10, max 1000
mode: page increments the query parameter from start_page; mode: link
follows the Link: rel="next" response header (RFC 8288, relative targets
resolved). Fetching stops on an empty (or non-array) JSON page, a missing
next link, or max_pages — hitting the cap prints a warning so truncation is
never silent. Pages are aggregated into a single JSON array before transforms;
each page request reuses the standard auth/retry/size-cap machinery, and the
aggregated result is what lands in the disk cache.
Retries with linear backoff cover network errors, 429 and 5xx. Successful
payloads land in the shared disk cache (<hash>.body + <hash>.meta.json,
sha256-verified; corrupted entries are evicted). Within cache_ttl the cache
is served without touching the network; after that, a failed refetch can serve
the stale copy for stale_ttl (stale_if_error).
CLI: --offline (cache only), --refresh-external-sources (ignore fresh
cache), --external-source=NAME (narrow the refresh), --clear-external-cache.
Payload CMS (GO-068)
Payload's REST API is a plain JSON API — GET /api/{collection}?limit=&depth=
returns { "docs": [...], "totalDocs": N, ... }. The http connector already
covers it; no dedicated adapter is needed. Fetch one page with a high limit
and unwrap the docs array with transform.select:
1external_sources:
2 enabled: true
3 sources:
4 payload_posts:
5 type: http
6 url: https://cms.example.com/api/posts
7 format: json
8 headers:
9 Authorization: "$PAYLOAD_API_KEY" # resolved from the environment
10 query:
11 limit: "1000" # one request; raise to cover the collection
12 depth: "2" # expand relationships (authors, media)
13 transform:
14 select: docs # Payload wraps results in { docs: [...] }
The extracted array lands in .ExternalData.payload_posts for templates. To
merge the documents into the site as native pages instead, add mode: content
and a content_map saying which document field is the title and which is
the body — see Records as pages below. Keep the API key in
the environment (PAYLOAD_API_KEY), never in the committed config.
1 mode: content
2 content_map:
3 title: title
4 slug: slug
5 content: content
6 date: publishedAt
7 type: "=post"
Note. The generic
pagination:block is not used here: it aggregates pages that are each a bare JSON array, whereas Payload wraps every page in a{ docs: [...] }object. Raiselimitto fetch the collection in one request instead. A collection larger than a single reasonablelimitis the one case that would justify a dedicated Payload preset.
Records as pages
A source's records are data a template can iterate. With a content_map they
become pages instead — one page per record, with their own URLs, taxonomy
archives, sitemap entries, feeds and .md/.json outputs, exactly like a page
written by hand.
The build could already do this for a CMS database. Every other source type
accepted mode: content in its configuration and then ignored it, because the
one thing a tool cannot guess was missing: which field of a record is the
title, and which is the body. content_map says so.
1external_sources:
2 enabled: true
3 sources:
4 products:
5 type: file
6 path: data/products.csv
7 format: csv
8 mode: content
9 content_map:
10 title: name # a field of the record
11 slug: "product-{{.sku}}" # a template over the record
12 content: description
13 date: updated_at
14 type: "=product" # a constant: every record gets this
15 taxonomies:
16 tag: categories # a list field, or "a,b" in one CSV column
17 content_format: markdown # markdown (default) | html | text
18 content_errors: warn # warn (default) | strict
19 max_rows: 10000 # the record cap
Three CSV rows become three pages at /product-w-1/, /product-w-2/ and
/product-w-3/, each rendered through the product type's template if the
theme has one and the post template otherwise.
How a mapping value is read
| Written as | Means |
|---|---|
name |
the record's name field |
attributes.title |
a dotted path into a nested record |
=product |
a constant — every record gets this value |
product-{{.sku}} |
a template over the record, for a value built from several fields |
The leading = is the one piece of syntax worth remembering. Writing
type: product looks right and means "the record's product field", which no
record has; the build says so and points at "=product".
The fields you can map
title (required), slug, content, excerpt, description, date,
modified, type, status, link, image, lang, and taxonomies as a
map of taxonomy name to record field.
A missing slug is derived from the title. A missing type is post; page
routes the record onto the page pipeline. Dates are read as ISO-8601,
YYYY-MM-DD, DD/MM/YYYY, RFC 1123 or a Unix timestamp.
What stops the build, and what only warns
- Two records mapping to the same slug stops it, always. Two pages at one URL means one of them is silently lost, and no policy makes that acceptable. The error suggests the fix: a slug template with a field that is unique.
- More records than
max_rowsstops it, so a source that grew by a thousand does not quietly become a thousand pages. - A record with no title is skipped with a warning, and a field the
mapping names but the record does not have is warned about and left empty.
content_errors: strictpromotes both to build failures.
content_format
markdown (the default) and html both pass the body to the same renderer
every page body goes through, which renders Markdown and passes HTML through.
text is the one that changes something: it escapes the characters that turn
prose into markup, so a product description with a 50% *off* in it arrives as
its author wrote it rather than half-italic.
Still data, too
A mapped source keeps its .ExternalData.<name> namespace. The records are
pages and data, so a template that already iterated them still can.
SQL (type: sql)
Drivers: mysql, mariadb, postgres, sqlite (pure Go, no cgo). Queries
live only in configuration — never in templates — and are statically
validated: a single statement, SELECT (or WITH … SELECT) only, no
piggybacked statements. Each query runs under the source timeout with a hard
max_rows cap (default 10000); exceeding it is an error, not a silent
truncation. DSNs are scrubbed from driver errors.
1inventory:
2 type: sql
3 driver: mysql
4 dsn: "$INVENTORY_DSN"
5 queries:
6 products:
7 sql: |
8 SELECT id, name, slug, price
9 FROM products
10 WHERE published = true
11 max_rows: 10000
{{ range .ExternalData.inventory.products }}{{ .name }}: {{ .price }}{{ end }}
Operational rules: use a dedicated read-only database user, and TLS for remote connections (configure both in the DSN).
CMS adapters (type: cms)
1wordpress:
2 type: cms
3 adapter: wordpress # wordpress | drupal | movable_type
4 driver: mysql
5 dsn: "$WORDPRESS_DSN"
6 mode: content # content (default) | data
mode: content merges the import into the site before URL, translation,
taxonomy and collision processing — imported posts render, paginate, join
feeds, sitemaps and archives exactly like native content. Imported authors
merge without overwriting IDs the local metadata already defines. mode: data
only exposes the import under .ExternalData.<name> (pages/posts/authors/
taxonomies/media/metadata maps) — the data view is available in both modes.
WordPress
wp_posts, wp_users, wp_terms/wp_term_taxonomy/wp_term_relationships,
wp_postmeta and attachments. post and custom post types render as posts;
page maps to pages. category/post_tag feed the legacy fields; other
taxonomies land in the page's taxonomies map, so
dynamic taxonomies pick them up. User-facing custom fields
(keys not starting with _) land in .Extra.
1wordpress:
2 table_prefix: wp_
3 post_types: [post, page, guide]
4 statuses: [publish]
5 include_media: true
6 include_custom_fields: true
7 include_taxonomies: true
Drupal (8–11)
node_field_data, node__body, users_field_data,
taxonomy_term_field_data/taxonomy_index (vocabularies → taxonomies map)
and path_alias — aliases are preserved as explicit links, so Drupal URLs
survive the migration. With include_fields: true, dynamic node__field_*
tables are discovered per engine and land in .Extra. Drupal 7 uses a
different schema and is deferred.
1drupal:
2 version: 10
3 bundles: [article, page]
4 published_only: true
5 include_fields: true
Movable Type
mt_entry (released entries and pages), mt_author,
mt_category/mt_placement, mt_tag/mt_objecttag, mt_asset and —
opt-in — visible mt_comment rows (GO-058).
1movable_type:
2 include_entries: true
3 include_pages: true
4 include_assets: true
5 include_comments: true # visible comments → the page's .Extra["comments"]
With include_comments: true each imported entry carries its approved
(comment_visible = 1) comments as a list of maps (author, email, url,
created_on, text) under .Extra["comments"], ready for templates. Unapproved
and spam comments are never imported. Default false — identical behaviour
to previous releases.
Template API
{{ .ExternalData.products }} — parsed data per source
{{ .ExternalDataMeta.products.FetchedAt }} — metadata (index and page contexts)
{{ getExternal "products" }} — helper, works in every context
{{ getExternalMeta "products" }} — Metadata struct
Metadata fields: SourceType, Identifier (always credential-free),
FetchedAt, FromCache, Stale, Checksum, RecordCount, ContentType.
Transformations
1transform:
2 select: data.items # dot path into the parsed structure
select unwraps API envelopes before the data reaches templates. There is
deliberately no scripting, eval or embedded query runtime.
Migration notes
.Data(localdata/files) is unchanged; the new system is additive.- MDDB remains a separate content source; it may implement the same connector interface later.
- Builds stay deterministic offline:
--offline+ a warmed cache produces byte-identical output.
Troubleshooting
failed at fetch … blocked address— the host resolves to a private IP; setallow_private: truefor self-hosted APIs.offline mode and no cached copy— run once online, or setfail_on_cache_miss: falseto downgrade the miss to a warning.must reference an environment variable— secrets belong in the environment, not the config file.result exceeds max_rows— raise the query'smax_rowsor narrow the SQL.
Deferred (phase 7+)
- Direct-URL template helpers (
getJSON/getCSV/getXML). - Adapters: Ghost, Strapi, Contentful, Sanity, Notion, Airtable, Google Sheets, GitHub, GitLab; Drupal 7.
watch: truerebuilds on file-source changes.- Example CMS projects with seed scripts.