@contentrain/emitter-astro 0.14.4 → 0.15.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.
package/README.md CHANGED
@@ -44,6 +44,7 @@ await writeEmit(result, './out/site')
44
44
  | Runtime components (`comments`, `form`) | With `input.runtime` (`RuntimeBinding`: the provider's origin + project id) these get real implementations: a custom element (`<cr-comments>`, `<cr-form>`) carrying the binding, and a zero-dependency client (`src/lib/embed.ts`) that fetches, renders and submits against the provider's public API — the same contract as `@contentrain/query/cdn`'s `CommentsClient`/`FormsClient`, inlined so the site depends on nothing but Astro. Comments key on the page's entry address (`EmitPost.entry`: model, entry id, locale) and show approved comments only; pending ones live on the provider. A form names its model (`ComponentDef.model`). Honeypot and Turnstile follow the config. No credential is ever emitted. Without a binding, or without a model for a form, the component stays a placeholder and a warning says so. What they say to visitors is content: the `ui-strings` dictionary (`UI_STRINGS_MODEL`, 23 keys in `UI_STRING_DEFAULTS`) at `.contentrain/content/site/ui-strings/{locale}.json` is read at build time for the page's language (`src/lib/ui-strings.ts`), edited in Studio or through MCP, and applied by a rebuild. Missing keys show the English default; a non-English page with no dictionary is reported in the build log, and in the emit report for languages not in `options.uiStrings.locales`. `options.uiStrings.dir` moves the directory. |
45
45
  | Regions bound to a query | `ComponentPlacement.query` binds a mounted region (a "recent posts" block) to a query, so it stays current instead of freezing as a clone: the layout reads the result set from `EmitContent.queries[id]` and renders it at the marker through `sections`, else `item_template` — the same path a list page takes. A query missing from the content drops the binding (warned; the region stays cloned). More than one result set is warned and stops the build. No item markup is warned and renders a plain list of links, as a list page does — never an empty region. |
46
46
  | Other components (`src/components/*.astro`) | `<cr-component>` placeholders carrying type/source/variants — the emitter cannot invent an ad slot or a related-posts box; the marker keeps the spot. |
47
+ | Images (`src/lib/images.ts`, `src/lib/optimize-images.ts`) | Content bodies are HTML, so `<Image>` cannot reach them; each page's content instead runs one build-time pass. Images on an allowed host — migrated media on the runtime host always, plus `options.images.remotePatterns` — go through `astro:assets` `getImage()` once the host has answered 200 with an image (a HEAD, or a GET where HEAD is refused; 10 s timeout, one check per URL) — Astro downloads the file later in the build, outside any error handling, so an image that 404s would otherwise fail `astro build`: WebP, a `srcset` (480/768/1024/1600, none wider than the width the HTML declares), `width`/`height` inferred, `sizes` when missing. The source's own `srcset`/`sizes` are replaced, or dropped when the optimized image has no `srcset`, so the browser cannot pick the unoptimized variants. Tags are read with quoted `>` allowed, and `src` is entity-decoded (`&amp;`) before use. Every image gets `decoding="async"` and `loading` (the first eager with `fetchpriority="high"`, the rest lazy); existing attributes are respected, SVG/GIF/`data:`/`data-cr-keep` are never re-encoded, and a failed optimization keeps the original. `astro.config.mjs` gets the same `image.remotePatterns`, `sharp` is added. Theme chrome is left as the source had it. `images: { enabled: false }` turns it off. |
47
48
  | Split viewports | With `viewport_strategy: 'split'`, `build:desktop` / `build:mobile` scripts scaffold per-device production. |
48
49
 
49
50
  Everything content-shaped flows through JSON data files, never through generated template source — determinism and safe escaping fall out of that one rule.
@@ -77,15 +78,24 @@ at.
77
78
 
78
79
  | Tag | Source |
79
80
  |---|---|
80
- | `<title>` | `EmitPost.title` · `QueryPage.title` · `RouteModel.title` |
81
- | `description`, `og:description`, `twitter:description` | `EmitPost.description`, else `excerpt` with markup stripped and cut at a word boundary |
81
+ | `<title>` | `EmitPost.seo_title` (the title an SEO plugin composed, "Post – Site"), else `EmitPost.title` · `QueryPage.title` · `RouteModel.title`. The entry's own `title` stays its Article headline and last breadcrumb |
82
+ | `og:title`, `og:description`, `og:image` | `open_graph` on `EmitPost` or `QueryPage` — share-card values the source set by hand — else the page's title, description and image. `og:image:*` is printed only when the image is `image` itself |
83
+ | `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` | `twitter` on `EmitPost` or `QueryPage`, else the Open Graph values; the card is `summary` / `summary_large_image` as given, else by whether there is an image. The template head's `twitter:site` — the site's handle, the same on every page — is kept |
84
+ | `description` | `EmitPost.description`, else `excerpt` with markup stripped and cut at a word boundary |
82
85
  | `canonical`, `og:url` | The address Astro generated (`Astro.url` + `site`), so it cannot disagree with the emitted page. `EmitPost.canonical` overrides |
83
- | `og:image`, `twitter:image` | `EmitPost.image`, else a `featured` entry that is already a path. A bare file name is skipped — only the producer knows where media is served |
86
+ | Page image (`og:image` / `twitter:image` default) | `EmitPost.image`, else a `featured` entry that is already a path. A bare file name is skipped — only the producer knows where media is served |
84
87
  | `og:image:width` / `height` / `type` | `image_meta` on `EmitPost` or `QueryPage` — the producer measured the file; the emitter never sees it. Printed only beside `image` (a `featured` fallback is a different file), and only values that are true of an image: positive integer sizes, an `image/…` type |
85
88
  | `og:site_name` | `ProjectIR.site.title` |
86
89
  | Structured data graph | One JSON-LD `@graph` per page: `WebPage` (`CollectionPage` on a list) with `@id` = the built address, `inLanguage` = the page `lang`, and `isPartOf` → the site's own WebSite `@id` when the kept head declares one (never an invented WebSite); a `BreadcrumbList` from `EmitPost.breadcrumbs` / `QueryPage.breadcrumbs` — the trail **without** the page, which the emitter appends at its own address; and on entries the `Article`, whose `mainEntityOfPage` is that `@id`. A trail with a crumb lacking a name or a site-root-relative path is dropped, with a warning. No trail, no BreadcrumbList. Without `site` there is no page node, only an entry's Article |
90
+ | Source structured data | `schema` on `EmitPost` or `QueryPage`: the page's JSON-LD as the source's SEO plugin rendered it (a `@graph` object, a node array or one node). It is printed **instead of** the generated graph, so FAQ, HowTo, Product and the plugin's own WebPage/Article survive. A WebSite `SearchAction` is dropped, and so is every node the kept head already carries — WebSite, Organization, its logo — matched by `@id` as an address (the scheme, the host's case and a slash before `#` do not count). The plugin's graph is the whole of the page's structured data: a plugin graph without a BreadcrumbList prints none, even when `breadcrumbs` is set. Addresses in the graph are printed as given — the migration keeps the source's, so rewrite only what moved (media). A value with no typed node is not printable: that page gets the generated graph, with a warning |
87
91
  | `<link rel="alternate" hreflang>`, `og:locale:alternate` | Entry pages whose content-store entry (`EmitPost.entry`) exists in another locale on the site. The address is computed from each route's own pattern and the post's parameters — the values `getStaticPaths` uses — and the language is the page's `lang` (post locale, else route locale, else site default). Every page lists itself; `x-default` is the version in the site's default locale. A post two routes generate, or an entry where two pages claim one language, gets none, with a warning. The template page's own hreflang links are removed from the head chrome |
88
- | `og:locale` | The page's `lang` |
92
+ | `og:locale`, `og:locale:alternate` | The page's `lang` in Open Graph's `ll_RR` form (`tr-TR` → `tr_TR`). A language without a region (`en`) has no such form and is left out rather than guessed — give `ProjectIR.site.locales` the region, as WordPress's `<html lang>` carries it |
93
+ | Page or post | A route of kind `page` renders its entries as web pages — `og:type` `website`, a `WebPage` node and no Article or `article:*` tags, as WordPress's SEO plugins print a page. Every other entry route renders articles |
94
+ | Article publisher | The node the kept head says publishes the site — the WebSite's own `publisher` reference (Yoast and Rank Math write one), else the head's first Organization — named by `@id`, so its logo and profile count. Without one: the site by `ProjectIR.site.title` and address, never an invented logo |
95
+ | Article image | An `ImageObject` with width and height when `image_meta` measures the image; the bare URL otherwise |
96
+ | Authors | `EmitPost.author_url` — the author's archive page — becomes the Article author's `url`. On that archive, `QueryPage.profile` (`name`, `description`, `image`, `same_as`) makes the page a `ProfilePage` whose `mainEntity` is a `Person` at the page's own address (`#person`), with `sameAs` for http(s) profiles only — the same address the articles name, so an engine reads one person |
97
+ | `WebPage` dates | `datePublished` / `dateModified` from `published_at` / `modified_at`, on pages and posts alike |
98
+ | `<meta name="robots">` | `noindex` / `nofollow` on `EmitPost` or `QueryPage` (the source page's robots, e.g. Yoast `robots.index`): `noindex`, `nofollow` or `noindex, nofollow`. Neither set, no tag. The template page's own `robots` and `googlebot` tags are removed from the head chrome, so one page's `noindex` cannot spread to every page. Their site-wide settings (Yoast's `max-image-preview:large, max-snippet:-1, max-video-preview:-1`, anything but index/noindex/follow/nofollow/all/none) are kept and printed on every page after the page's own directives |
89
99
  | `article:published_time` / `modified_time`, Article JSON-LD dates | `EmitPost.published_at` / `modified_at` (ISO 8601 — `dates` holds display strings, which schema.org cannot read) |
90
100
 
91
101
  Absolute URLs need `site` in `astro.config.mjs`, which the emitter fills from
@@ -100,6 +110,15 @@ the body is page content and `<title>` is legal inside `<svg>`, so cutting into
100
110
  it to fix an invisible tag would break real markup. Lift them into the head
101
111
  chunk, or drop them at capture.
102
112
 
113
+ ### From a Bridge SEO export
114
+
115
+ `seoFromRawEntry(blocks, { serving, url })` maps one page's `RawSeo.entries[address]` — what each SEO plugin holds for it — to the fields above (`seo_title`, `description`, `canonical`, `noindex`/`nofollow`, `open_graph`, `twitter`, `schema`), ready to spread into an `EmitPost` or `QueryPage`:
116
+
117
+ - The serving plugin's block is read (`RawSeo.serving`), else the first of Yoast, Rank Math and AIOSEO that has one.
118
+ - A block not marked `resolved` holds stored values: its unrendered templates, in that plugin's own syntax (Yoast `%%title%%`, Rank Math `%title%`, AIOSEO's named tags such as `#post_title` — not any `#word`, so a hashtag survives), are dropped so the page's own values fall in, and its literal values are kept.
119
+ - Robots come from `robots_served` — what the page actually carried — and from the plugin's setting only without it.
120
+ - A canonical equal to the page's own `url` is left out; only one that points elsewhere becomes an override.
121
+
103
122
  `options.seo: false` turns all of this off: the source head travels verbatim and
104
123
  the layout prints its own `<title>`, exactly as before.
105
124
 
@@ -125,10 +144,46 @@ Nothing is disallowed. A WordPress `robots.txt` typically keeps crawlers out of
125
144
  sitemap to name — the integration is not added, `robots.txt` carries no
126
145
  `Sitemap:` line (a relative one is invalid), and a warning says so.
127
146
 
147
+ A `noindex` page is left out of the sitemap: the emitter computes its address
148
+ from the route pattern and the entry's parameters (the values `getStaticPaths`
149
+ builds the page from) and passes the list to `sitemap({ filter })`. This holds
150
+ with `options.seo: false` too, and a warning then says the producer's head must
151
+ carry the robots tag.
152
+
128
153
  `options.sitemap: false` leaves out both. It is independent of `options.seo`: a
129
154
  producer that writes its own meta tags has not thereby said it writes its own
130
155
  sitemap.
131
156
 
157
+ ## Trailing slash
158
+
159
+ A WordPress site was indexed under its own form of every address — `/hello/` with the default permalinks, `/hello` with a structure that ends without a slash (`RawRouting.trailing_slash`). The migrated site serves and names each page in that form, so no indexed URL turns into a redirect.
160
+
161
+ | `options.trailingSlash` | Build | Served at | Named as (canonical, og:url, hreflang, sitemap, feed, llms.txt) |
162
+ |---|---|---|---|
163
+ | `true` (default) | `build.format: 'directory'` — `/hello/index.html` | `/hello/` | `/hello/` |
164
+ | `false` | `build.format: 'file'`, `trailingSlash: 'never'` — `/hello.html` | `/hello`: Netlify and Cloudflare Pages serve a file build that way; for Vercel the emitter adds `cleanUrls: true, trailingSlash: false` to `vercel.json` (unless `redirectHost` names another host) | `/hello` |
165
+
166
+ The page's own address is taken from the one Astro built it at — a file build names it `/hello.html` — and put into the source's form (`pagePath` in the emitted runtime), so the canonical cannot disagree with the page. A permalink structure ending in `.html` (`/%postname%.html`) is not expressible as an Astro route pattern and is reported, like any other unsupported pattern.
167
+
168
+ ## Feed and llms.txt
169
+
170
+ WordPress serves every site's newest posts as RSS at `/feed/`, and readers, aggregators and newsletter tools subscribe to it; Yoast writes an `llms.txt` that tells a language model what the site holds. The emitter builds both, as Astro endpoints over the data files the entry pages are built from, and names each entry by the address its own page has — the route pattern filled with the entry's parameters — so neither can list a page the site does not build.
171
+
172
+ | File | What it holds |
173
+ |---|---|
174
+ | `/feed.xml` (`src/pages/feed.xml.ts`) | RSS 2.0: the 10 newest posts (WordPress's default `posts_per_rss`) of the `posts` collection, newest first by `published_at`, each with its title, address, date, author and description (`description`, else the excerpt). The channel is `ProjectIR.site.title` and `options.siteDescription` |
175
+ | `/llms.txt` (`src/pages/llms.txt.ts`) | The [llmstxt.org](https://llmstxt.org) shape: the site's name, `options.siteDescription` as its summary, and a section per collection the site builds entry pages for — posts first, then pages — with the newest 100 links of each. An entry the source kept out of search (`noindex`) is left out |
176
+
177
+ Both describe the site in its default language (`ProjectIR.site.locales[0]`): a route in another language, and an entry whose `locale` is another, are left out. Both need `site.url`, since they name pages by absolute address; without it neither is built, with a warning.
178
+
179
+ The feed is built at `/feed.xml`, not at `/feed/`: a static build writes an endpoint at `/feed/` as a file named `feed`, which no host serves at `/feed/`. The theme's head link to the site's main feed (`/feed/`, `/feed/rss2/`, `?feed=rss2`) is pointed at `/feed.xml`, so browsers and readers that discover the feed from a page find the new one. A reader already subscribed to `/feed/` is sent on with a 301 to `/feed.xml`, written like the site's own redirects: in `astro.config` and in the host's redirect file (see **Redirects**), which is what gives it a real status — feed readers do not follow a static redirect page. A `/feed/` rule the source itself holds wins, and no rule is written when the site builds a page at `/feed/`. The site's other feed links in the head are removed, with a warning (see **Archive feeds** below). A feed on another host (FeedBurner, a newsletter tool) is left alone; `www.` and the bare host count as one site.
180
+
181
+ The feed lists every post, as WordPress's does — a post the source kept out of search included. An entry whose `canonical` points elsewhere is listed at its own address.
182
+
183
+ `options.feed: false` and `options.llms: false` leave each out.
184
+
185
+ **Archive feeds.** WordPress serves a feed for every archive page — `/category/news/feed/`, `/tag/x/feed/`, `/author/ada/feed/`. Every dynamic archive route with a query in the site's default language (not its paginated continuation) gets one per page it builds, at `<archive>/feed.xml` (`src/pages/<archive>/feed.xml.ts`): the 10 newest posts that page lists, each at its own address, titled with the page's title. Each archive page links its feed in its head, and `<archive>/feed/` gets a 301 to it in `astro.config` and the host's redirect file, as the main feed does. The feed redirects pass the checks the site's own rules do — none over a page the site builds or a case twin of one, and a source rule for the same address wins — and in a host file with a rule limit they come after the site's own rules, `/feed/` first, so an overflow drops a feed redirect, which the warning counts. The template head's own archive feed link names one term's feed on every page, so it is removed with the other feed links the site does not build (comments, Atom). `options.feed: false` turns these off with the main feed.
186
+
132
187
  ## Binding the runtime later
133
188
 
134
189
  `src/data/runtime.json` is the only place the binding lives; the components
@@ -153,6 +208,51 @@ body and runtime entry address. Missing or ambiguous data fails the Astro build.
153
208
  Dynamic routes continue to use `getStaticPaths`. Generated tsconfig files exclude
154
209
  `public`, `dist`, and `node_modules`, so copied WordPress assets are not typechecked.
155
210
 
211
+ ## Redirects
212
+
213
+ `input.redirects` takes the live site's own redirect rules (`RawIR.redirects`,
214
+ e.g. what the Bridge reads from Redirection, Yoast Premium, Rank Math, Safe
215
+ Redirect Manager and `_wp_old_slug`). The emitter writes only rules that are
216
+ one address to one address:
217
+
218
+ - `match` absent or `url`, and not `regex`;
219
+ - status 301, 302, 307 or 308 (absent means 301);
220
+ - `from` a site-root path with no query string, no `[`/`]`, and not ending in a
221
+ file name (the directory build writes `/old.php` as `/old.php/index.html`,
222
+ which a host does not serve at `/old.php`);
223
+ - `to` a site-root path or an `http(s)` URL.
224
+
225
+ Those go to `astro.config.mjs` `redirects`, sorted by `from`, with the
226
+ percent-encoding decoded. Everything else comes back in
227
+ `EmitResult.redirects.manual` with its reason, and one warning gives the count:
228
+ those rules have to be set up at the host. A pattern turned into a literal
229
+ `from` would redirect the wrong address, so the emitter never guesses.
230
+
231
+ A rule on an address the migrated site builds a page at is not written either;
232
+ the page is kept and the rule is returned. Astro does not refuse that pair: it
233
+ writes the redirect over the page without an error. Duplicate `from`s keep the
234
+ first rule, and a rule that points at its own address is returned.
235
+
236
+ In a static build Astro serves each redirect as an HTML page with a meta
237
+ refresh, `noindex`, and a canonical link to the target. Those pages stay as the
238
+ fallback for any host.
239
+
240
+ A static build has no HTTP status, so the emitter also writes the rules into the
241
+ host's own redirect file, which answers the request before any page is served:
242
+
243
+ | `options.redirectHost` | File | Note |
244
+ |---|---|---|
245
+ | `netlify` | `public/_redirects` | Forced (`301!`): Netlify serves an existing file before an unforced rule, and the build writes a fallback page at every redirected path |
246
+ | `cloudflare` | `public/_redirects` | Unforced: Cloudflare Pages always follows its redirects, even when an asset matches |
247
+ | `vercel` | `vercel.json` `redirects` | `statusCode` per rule; path-to-regexp syntax escaped |
248
+ | unset | Netlify `public/_redirects` and `vercel.json` | A Cloudflare Pages site should name its host |
249
+
250
+ Each path is written percent-encoded, with and without its trailing slash.
251
+ `EmitResult.redirects.host_files` names the files written. A `from` containing
252
+ `*` or a `:` segment is a pattern to a host file; it is left to the meta-refresh
253
+ fallback, with a warning. A rule that differs from a built page only in letter case is returned too: Netlify matches rules case-insensitively, and macOS and Windows file systems cannot hold `/About/` beside `/about/`, so it would be served over the page. Cloudflare Pages and Vercel files stop at 1,000 rules (2,000 entries with both slash forms, their static limit); the rest are named in `EmitResult.redirects.host_over_limit`, with a warning, and keep only the meta-refresh fallback there. Netlify's file has no limit.
254
+
255
+
156
256
  ## Route collisions
157
257
 
158
258
  WordPress serves posts and pages from the same root and tells them apart in the