@contentrain/emitter-astro 0.16.1 → 0.18.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/dist/index.mjs CHANGED
@@ -722,9 +722,7 @@ function scaffoldFiles(ir, options, noindex = [], redirectsConfig = null, runtim
722
722
  "@tailwindcss/vite": "^4.0.0"
723
723
  } : {},
724
724
  ...sitemap ? { "@astrojs/sitemap": "^3.7.0" } : {},
725
- ...images ? { sharp: "^0.34.0" } : {}
726
- },
727
- devDependencies: {
725
+ ...images ? { sharp: "^0.34.0" } : {},
728
726
  "@astrojs/check": "^0.9.0",
729
727
  typescript: "^5.7.0"
730
728
  }
@@ -1733,7 +1731,9 @@ function scanTags(html) {
1733
1731
  });
1734
1732
  i = j + 1;
1735
1733
  if (!closing && RAW_TEXT_TAGS.has(name)) {
1736
- const close = html.toLowerCase().indexOf(`</${name}`, i);
1734
+ const end = new RegExp(`</${name}`, "gi");
1735
+ end.lastIndex = i;
1736
+ const close = end.exec(html)?.index ?? -1;
1737
1737
  i = close === -1 ? html.length : close;
1738
1738
  }
1739
1739
  }
@@ -3766,7 +3766,7 @@ const REDIRECT_STATUSES = [
3766
3766
  /** Whitespace, a control character or DEL anywhere in the string. */
3767
3767
  const hasControlOrSpace = (value) => [...value].some((char) => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127);
3768
3768
  /** `/old/` and `/old` are one address in the directory build format. */
3769
- function addressKey$1(path) {
3769
+ function addressKey$2(path) {
3770
3770
  return path === "/" ? "/" : `${path.replace(/\/+$/, "")}/`;
3771
3771
  }
3772
3772
  /**
@@ -3777,7 +3777,7 @@ function addressKey$1(path) {
3777
3777
  function builtAddresses(routes, content) {
3778
3778
  const out = /* @__PURE__ */ new Map();
3779
3779
  const put = (path, routeId) => {
3780
- if (path !== null && !out.has(addressKey$1(path))) out.set(addressKey$1(path), routeId);
3780
+ if (path !== null && !out.has(addressKey$2(path))) out.set(addressKey$2(path), routeId);
3781
3781
  };
3782
3782
  for (const route of routes) {
3783
3783
  const pagePath = patternToPagePath(route.pattern);
@@ -3795,7 +3795,7 @@ function builtAddresses(routes, content) {
3795
3795
  return out;
3796
3796
  }
3797
3797
  /** The decoded path for a literal Astro redirect key, or why `from` cannot be one. */
3798
- function checkFrom(from) {
3798
+ function checkFrom(from, hostOnly = false) {
3799
3799
  if (!from.startsWith("/") || from.startsWith("//")) return { reason: "from is not a site-root path" };
3800
3800
  if (/[?#]/.test(from)) return { reason: "from has a query string or fragment — a static redirect matches the path only" };
3801
3801
  let path;
@@ -3808,9 +3808,24 @@ function checkFrom(from) {
3808
3808
  if (/[[\]]/.test(path)) return { reason: "from contains [ or ], which Astro reads as a route parameter" };
3809
3809
  const segments = path.split("/").filter(Boolean);
3810
3810
  if (segments.some((s) => s === "." || s === "..")) return { reason: "from contains a . or .. segment" };
3811
- if (/\.[a-z0-9]{1,5}$/i.test(segments.at(-1) ?? "")) return { reason: "from ends in a file name — the static build serves it as a directory, not at this address" };
3811
+ if (!hostOnly && /\.[a-z0-9]{1,5}$/i.test(segments.at(-1) ?? "")) return { reason: "from ends in a file name — the static build serves it as a directory, not at this address" };
3812
3812
  return { path };
3813
3813
  }
3814
+ /**
3815
+ * A path the build writes a file at: the emitter's own endpoints (robots,
3816
+ * llms.txt, every feed, @astrojs/sitemap's files), the 404 page, and the
3817
+ * directories of Astro's bundles and the legacy stylesheets. A host file
3818
+ * answers before the filesystem — a forced Netlify rule and every vercel.json
3819
+ * redirect — so a host-only rule here would shadow the file. `builtAddresses`
3820
+ * knows pages only. Compared without a trailing slash (a host file gets both
3821
+ * forms) and in lower case (Netlify matches rules case-insensitively).
3822
+ * WordPress's own `/sitemap.xml` and `/sitemap_index.xml` are not the build's:
3823
+ * a 301 from them is one a migration wants.
3824
+ */
3825
+ function buildOutputFile(path) {
3826
+ const p = (path.replace(/\/+$/, "") || "/").toLowerCase();
3827
+ return p === "/robots.txt" || p === "/llms.txt" || p === "/404.html" || p.endsWith("/feed.xml") || /^\/sitemap-(?:index|\d+)\.xml$/.test(p) || /^\/(?:_astro|styles)(?:\/|$)/.test(p);
3828
+ }
3814
3829
  function checkTo(to) {
3815
3830
  if (hasControlOrSpace(to)) return false;
3816
3831
  if (to.startsWith("/")) return !to.startsWith("//");
@@ -3823,9 +3838,11 @@ function checkTo(to) {
3823
3838
  }
3824
3839
  /**
3825
3840
  * Split the rules into what `astro.config` serves and what the host has to.
3826
- * `built` is `builtAddresses` of the emitted routes.
3841
+ * `built` is `builtAddresses` of the emitted routes. `hostOnly`: the rules go
3842
+ * to host files alone (`EmitInput.hostRedirects`), so the checks that hold
3843
+ * only for a static redirect page (a `from` naming a file) are left out.
3827
3844
  */
3828
- function planRedirects(redirects, built) {
3845
+ function planRedirects(redirects, built, options = {}) {
3829
3846
  const config = {};
3830
3847
  const written = [];
3831
3848
  const manual = [];
@@ -3849,16 +3866,20 @@ function planRedirects(redirects, built) {
3849
3866
  skip(`status ${status} is not one of ${REDIRECT_STATUSES.join("/")}`);
3850
3867
  continue;
3851
3868
  }
3852
- const from = checkFrom(redirect.from);
3869
+ const from = checkFrom(redirect.from, options.hostOnly);
3853
3870
  if ("reason" in from) {
3854
3871
  skip(from.reason);
3855
3872
  continue;
3856
3873
  }
3874
+ if (options.hostOnly && buildOutputFile(from.path)) {
3875
+ skip("from is a file the build writes — the host file would serve the redirect over it");
3876
+ continue;
3877
+ }
3857
3878
  if (!checkTo(redirect.to)) {
3858
3879
  skip("to is not a site-root path or an http(s) URL");
3859
3880
  continue;
3860
3881
  }
3861
- const key = addressKey$1(from.path);
3882
+ const key = addressKey$2(from.path);
3862
3883
  const owner = built.get(key);
3863
3884
  if (owner !== void 0) {
3864
3885
  skip(`the migrated site builds a page at ${key} (route ${owner}) — the page is kept`);
@@ -3880,7 +3901,7 @@ function planRedirects(redirects, built) {
3880
3901
  } catch {
3881
3902
  target = redirect.to;
3882
3903
  }
3883
- if (addressKey$1(target) === key) {
3904
+ if (addressKey$2(target) === key) {
3884
3905
  skip("to is the same address as from");
3885
3906
  continue;
3886
3907
  }
@@ -3985,6 +4006,172 @@ function hostRedirectFiles(config, host) {
3985
4006
  };
3986
4007
  }
3987
4008
  //#endregion
4009
+ //#region src/published.ts
4010
+ /**
4011
+ * Why an entry is not built, or null when it is. `now` is epoch ms. With
4012
+ * `requireStatus` an entry that carries no status is held back: the producer
4013
+ * said it passes every entry's status, so a missing one is a gap, not a
4014
+ * published entry.
4015
+ */
4016
+ function holdReason(post, now, requireStatus = false) {
4017
+ if (post.visibility !== void 0 && post.visibility !== "public") return "visibility";
4018
+ if (post.status === void 0 && requireStatus) return "status_missing";
4019
+ if (post.status !== void 0 && post.status !== "published") return "status";
4020
+ if (post.publish_at === void 0) return null;
4021
+ const at = Date.parse(post.publish_at);
4022
+ if (Number.isNaN(at)) return "publish_at_invalid";
4023
+ return at > now ? "scheduled" : null;
4024
+ }
4025
+ const bareHost = (host) => host.replace(/^www\./, "").toLowerCase();
4026
+ /** `/a/b` and `/a/b/` are one address. */
4027
+ const addressKey$1 = (path) => path === "/" ? "/" : `${path.replace(/\/+$/, "")}/`;
4028
+ /**
4029
+ * The held-back address an href points at, or null: a site-root path, or an
4030
+ * absolute URL on the site's own host (`www.` either way).
4031
+ */
4032
+ function withheldTarget(href, gone, siteUrl) {
4033
+ if (!gone.size) return null;
4034
+ let site;
4035
+ try {
4036
+ site = siteUrl ? new URL(siteUrl) : void 0;
4037
+ } catch {
4038
+ site = void 0;
4039
+ }
4040
+ let url;
4041
+ try {
4042
+ url = new URL(href, site ?? "http://site.invalid");
4043
+ } catch {
4044
+ return null;
4045
+ }
4046
+ if (!(site ? bareHost(url.host) === bareHost(site.host) : url.host === "site.invalid") || !/^https?:$/.test(url.protocol)) return null;
4047
+ let path = url.pathname;
4048
+ try {
4049
+ path = decodeURI(path);
4050
+ } catch {}
4051
+ return gone.has(addressKey$1(path)) ? path : null;
4052
+ }
4053
+ /**
4054
+ * The content with every unpublished entry removed — from its collection and
4055
+ * from the list pages that carry it as an item — and links to them unwrapped.
4056
+ * Content with nothing held back is returned as given.
4057
+ */
4058
+ function publishedContent(routes, content, now, siteUrl, requireStatus = false) {
4059
+ const withheld = [];
4060
+ const keep = (collection) => (post) => {
4061
+ const reason = holdReason(post, now, requireStatus);
4062
+ if (!reason) return true;
4063
+ withheld.push({
4064
+ collection,
4065
+ slug: post.slug,
4066
+ ...post.status ? { status: post.status } : {},
4067
+ ...post.visibility ? { visibility: post.visibility } : {},
4068
+ ...post.publish_at ? { publish_at: post.publish_at } : {},
4069
+ reason
4070
+ });
4071
+ return false;
4072
+ };
4073
+ const collections = content.collections ? Object.fromEntries(Object.entries(content.collections).map(([name, posts]) => [name, posts.filter(keep(name))])) : void 0;
4074
+ const isLive = (post) => holdReason(post, now, requireStatus) === null;
4075
+ const posts = content.posts?.filter(content.collections?.["posts"] ? isLive : keep(DEFAULT_COLLECTION));
4076
+ const queries = content.queries ? Object.fromEntries(Object.entries(content.queries).map(([id, pages]) => [id, pages.map((page) => page.items.every(isLive) ? page : {
4077
+ ...page,
4078
+ items: page.items.filter(isLive)
4079
+ })])) : void 0;
4080
+ const listedOnly = Object.values(content.queries ?? {}).flat().flatMap((p) => p.items).some((item) => !isLive(item));
4081
+ if (!withheld.length && !listedOnly) return {
4082
+ content,
4083
+ withheld,
4084
+ links: [],
4085
+ gone: /* @__PURE__ */ new Set()
4086
+ };
4087
+ const filtered = {
4088
+ ...content,
4089
+ ...posts ? { posts } : {},
4090
+ ...collections ? { collections } : {},
4091
+ ...queries ? { queries } : {}
4092
+ };
4093
+ const built = builtAddresses(routes, filtered);
4094
+ const gone = /* @__PURE__ */ new Set();
4095
+ for (const route of routes) {
4096
+ const pagePath = patternToPagePath(route.pattern);
4097
+ if (!pagePath || !pagePath.includes("[") || route.kind !== "single" && route.collection === void 0) continue;
4098
+ const collection = route.collection ?? "posts";
4099
+ for (const post of collectionItems(content, collection)) {
4100
+ const path = entryPath(route.pattern, {
4101
+ ...post.params,
4102
+ slug: post.slug
4103
+ });
4104
+ if (path && !isLive(post) && !built.has(addressKey$1(path))) gone.add(addressKey$1(path));
4105
+ }
4106
+ }
4107
+ const links = [];
4108
+ const unlink = (html, page) => {
4109
+ if (!html || !gone.size) return html;
4110
+ return html.replace(ANCHOR_RE, (anchor, double, single, inner) => {
4111
+ const to = withheldTarget((double ?? single ?? "").replace(/&amp;/g, "&"), gone, siteUrl);
4112
+ if (!to) return anchor;
4113
+ links.push({
4114
+ page,
4115
+ href: to
4116
+ });
4117
+ return inner;
4118
+ });
4119
+ };
4120
+ const unlinkPost = (post, page) => {
4121
+ const body = unlink(post.body, page) ?? post.body;
4122
+ const excerpt_html = unlink(post.excerpt_html, page);
4123
+ return body === post.body && excerpt_html === post.excerpt_html ? post : {
4124
+ ...post,
4125
+ body,
4126
+ ...excerpt_html !== void 0 ? { excerpt_html } : {}
4127
+ };
4128
+ };
4129
+ const pageOf = (collection, post) => {
4130
+ const route = routes.find((r) => (r.kind === "single" || r.collection !== void 0) && (r.collection ?? "posts") === collection);
4131
+ return (route && entryPath(route.pattern, {
4132
+ ...post.params,
4133
+ slug: post.slug
4134
+ })) ?? `${collection}/${post.slug}`;
4135
+ };
4136
+ if (filtered.collections) filtered.collections = Object.fromEntries(Object.entries(filtered.collections).map(([name, list]) => [name, list.map((p) => unlinkPost(p, pageOf(name, p)))]));
4137
+ if (filtered.posts) filtered.posts = filtered.posts.map((p) => unlinkPost(p, pageOf(DEFAULT_COLLECTION, p)));
4138
+ if (filtered.queries) filtered.queries = Object.fromEntries(Object.entries(filtered.queries).map(([id, pages]) => [id, pages.map((page) => {
4139
+ const route = routes.find((r) => r.query === id);
4140
+ const at = (route && entryPath(route.pattern, page.params)) ?? `query ${id}`;
4141
+ return {
4142
+ ...page,
4143
+ items: page.items.map((item) => unlinkPost(item, at))
4144
+ };
4145
+ })]));
4146
+ return {
4147
+ content: filtered,
4148
+ withheld,
4149
+ links,
4150
+ gone
4151
+ };
4152
+ }
4153
+ const ANCHOR_RE = /<a\b[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a\s*>/gi;
4154
+ /** The report lines for what was held back. */
4155
+ function withheldWarnings(withheld, links) {
4156
+ const out = [];
4157
+ if (withheld.length) {
4158
+ const count = (reason) => withheld.filter((w) => w.reason === reason).length;
4159
+ const parts = [
4160
+ count("visibility") && `${count("visibility")} password-protected or private`,
4161
+ count("status_missing") && `${count("status_missing")} with no status (options.requireStatus)`,
4162
+ count("status") && `${count("status")} not published (draft, in review, rejected or archived)`,
4163
+ count("scheduled") && `${count("scheduled")} scheduled for later — built by the first emit after their publish_at`,
4164
+ count("publish_at_invalid") && `${count("publish_at_invalid")} with a publish_at that is not a date`
4165
+ ].filter(Boolean);
4166
+ out.push(`unpublished: ${withheld.length} entries not built — ${parts.join("; ")}. They stay in the content store; EmitResult.withheld lists each`);
4167
+ }
4168
+ if (links.length) {
4169
+ const sample = links.slice(0, 5).map((l) => `${l.href} (on ${l.page})`).join(", ");
4170
+ out.push(`unpublished: ${links.length} links from published pages to unpublished entries kept as plain text — ${sample}${links.length > 5 ? " …" : ""}`);
4171
+ }
4172
+ return out;
4173
+ }
4174
+ //#endregion
3988
4175
  //#region src/seo-entry.ts
3989
4176
  /**
3990
4177
  * A plugin's unrendered template, in its own syntax: Yoast `%%title%%`,
@@ -4163,6 +4350,27 @@ function emitAstroProject(input) {
4163
4350
  files[path] = content;
4164
4351
  }
4165
4352
  };
4353
+ let now = Date.now();
4354
+ if (input.options?.now !== void 0) {
4355
+ const at = Date.parse(input.options.now);
4356
+ if (Number.isNaN(at)) warnings.push(`options.now "${input.options.now}" is not a date — publish_at is compared with the time of this emit`);
4357
+ else now = at;
4358
+ }
4359
+ const published = publishedContent(ir.routes, input.content ?? {}, now, ir.site.url, input.options?.requireStatus === true);
4360
+ const content = published.content;
4361
+ warnings.push(...withheldWarnings(published.withheld, published.links));
4362
+ const withheldManual = [];
4363
+ const toPublished = (rules) => rules?.filter((redirect) => {
4364
+ const target = withheldTarget(redirect.to, published.gone, ir.site.url);
4365
+ if (target) withheldManual.push({
4366
+ redirect,
4367
+ reason: `to ${target}, an entry that is not published — the build has no page there`
4368
+ });
4369
+ return !target;
4370
+ });
4371
+ const siteRedirects = toPublished(input.redirects);
4372
+ const hostOnlyRedirects = toPublished(input.hostRedirects);
4373
+ if (withheldManual.length) warnings.push(`unpublished: ${withheldManual.length} redirects point at unpublished entries — not written (EmitResult.redirects.manual has each)`);
4166
4374
  const siteLang = ir.site.locales?.[0] ?? "en";
4167
4375
  const linkable = ir.site.url ? linkSources(ir.routes, siteLang) : [];
4168
4376
  const feedSource = input.options?.feed !== false ? linkable.find((s) => s.collection === DEFAULT_COLLECTION) : void 0;
@@ -4170,11 +4378,11 @@ function emitAstroProject(input) {
4170
4378
  const archiveFeeds = feedSource ? archiveFeedSources(ir.routes, siteLang) : [];
4171
4379
  const archiveFeedRoutes = new Set(archiveFeeds.map((a) => a.routeId));
4172
4380
  if (!ir.site.url && (input.options?.feed !== false || input.options?.llms !== false)) warnings.push("site.url is empty — no RSS feed and no llms.txt are built; both name pages by absolute address");
4173
- const redirectPlan = input.redirects ? planRedirects(input.redirects, builtAddresses(ir.routes, input.content ?? {})) : void 0;
4174
- if (redirectPlan?.manual.length) warnings.push(`redirects: ${redirectPlan.manual.length} of ${input.redirects.length} rules not written to astro.config — set them up at the host (EmitResult.redirects.manual has each with its reason)`);
4381
+ const redirectPlan = siteRedirects ? planRedirects(siteRedirects, builtAddresses(ir.routes, content)) : void 0;
4382
+ if (redirectPlan?.manual.length) warnings.push(`redirects: ${redirectPlan.manual.length} of ${siteRedirects.length} rules not written to astro.config — set them up at the host (EmitResult.redirects.manual has each with its reason)`);
4175
4383
  const seo = input.options?.seo !== false;
4176
- const noindex = noindexPaths(ir.routes, input.content ?? {});
4177
- const built = builtAddresses(ir.routes, input.content ?? {});
4384
+ const noindex = noindexPaths(ir.routes, content);
4385
+ const built = builtAddresses(ir.routes, content);
4178
4386
  const sourceConfig = redirectPlan?.config ?? {};
4179
4387
  const claimed = new Set(Object.keys(sourceConfig).map((from) => from.replace(/\/+$/, "").toLowerCase()));
4180
4388
  const feedRules = [];
@@ -4189,7 +4397,7 @@ function emitAstroProject(input) {
4189
4397
  });
4190
4398
  };
4191
4399
  if (feedSource) addFeedRule(FEED_REDIRECT_FROM, FEED_PATH);
4192
- for (const source of archiveFeeds) for (const page of input.content?.queries?.[source.query] ?? []) {
4400
+ for (const source of archiveFeeds) for (const page of content.queries?.[source.query] ?? []) {
4193
4401
  const address = entryPath(source.pattern, page.params);
4194
4402
  if (address) addFeedRule(`${address}feed/`, `${address}feed.xml`);
4195
4403
  }
@@ -4201,22 +4409,60 @@ function emitAstroProject(input) {
4201
4409
  };
4202
4410
  const hasRedirects = Object.keys(redirectConfig).length > 0;
4203
4411
  add(scaffoldFiles(ir, input.options ?? {}, noindex, hasRedirects ? astroRedirectsConfig(sortedConfig(redirectConfig)) : null, input.runtime));
4412
+ const hostOnlyPlan = hostOnlyRedirects ? planRedirects(hostOnlyRedirects, built, { hostOnly: true }) : void 0;
4413
+ const hostOnlyConfig = {};
4414
+ const hostOnlyRule = /* @__PURE__ */ new Map();
4415
+ const hostOnlyManual = [...hostOnlyPlan?.manual ?? []];
4416
+ if (hostOnlyPlan) {
4417
+ const byFrom = new Map(hostOnlyPlan.written.map((rule) => [decodedPath(rule.from), rule]));
4418
+ for (const [from, rule] of Object.entries(hostOnlyPlan.config)) {
4419
+ const redirect = byFrom.get(from);
4420
+ const key = from.replace(/\/+$/, "").toLowerCase();
4421
+ if (claimed.has(key)) {
4422
+ hostOnlyManual.push({
4423
+ redirect,
4424
+ reason: `another rule already redirects ${from}`
4425
+ });
4426
+ continue;
4427
+ }
4428
+ claimed.add(key);
4429
+ hostOnlyConfig[from] = rule;
4430
+ hostOnlyRule.set(from, redirect);
4431
+ }
4432
+ }
4204
4433
  const feedOrder = new Map(feedRules.map((rule, index) => [decodedPath(rule.from), index]));
4205
4434
  const feedKeys = Object.keys(feedPlan.config).toSorted((a, b) => (feedOrder.get(a) ?? 0) - (feedOrder.get(b) ?? 0));
4206
4435
  const hostOrder = {
4207
4436
  ...sortedConfig(sourceConfig),
4208
- ...Object.fromEntries(feedKeys.map((key) => [key, feedPlan.config[key]]))
4437
+ ...Object.fromEntries(feedKeys.map((key) => [key, feedPlan.config[key]])),
4438
+ ...sortedConfig(hostOnlyConfig)
4209
4439
  };
4210
- const hostRedirects = hasRedirects ? hostRedirectFiles(hostOrder, input.options?.redirectHost) : void 0;
4440
+ const hostRedirects = Object.keys(hostOrder).length ? hostRedirectFiles(hostOrder, input.options?.redirectHost) : void 0;
4441
+ const hostOnly = (from) => hostOnlyRule.has(from);
4442
+ for (const from of hostRedirects?.skipped ?? []) if (hostOnly(from)) hostOnlyManual.push({
4443
+ redirect: hostOnlyRule.get(from),
4444
+ reason: "from contains \":\" or \"*\", which host redirect files read as a pattern"
4445
+ });
4446
+ const namedHost = input.options?.redirectHost;
4447
+ const hostOnlyOver = (hostRedirects?.over_limit ?? []).filter(hostOnly);
4448
+ if (namedHost !== void 0) for (const from of hostOnlyOver) hostOnlyManual.push({
4449
+ redirect: hostOnlyRule.get(from),
4450
+ reason: `left out of ${hostRedirects.written.join(", ")} at its rule limit (${HOST_RULE_LIMIT / 2} rules) — a host-only rule has no meta-refresh page to fall back on`
4451
+ });
4452
+ else if (hostOnlyOver.length) warnings.push(`hostRedirects: ${hostOnlyOver.length} host-only rules not written to vercel.json at its rule limit (${HOST_RULE_LIMIT / 2} rules) — public/_redirects (Netlify) has them; on Vercel they are not served. Name the host (options.redirectHost) to have them reported as manual`);
4453
+ const unservedHostOnly = new Set(hostOnlyManual.map((m) => m.redirect));
4454
+ if (hostOnlyManual.length) warnings.push(`hostRedirects: ${hostOnlyManual.length} of ${hostOnlyRedirects.length} host-only rules not written — set them up at the host (EmitResult.redirects.manual has each with its reason)`);
4211
4455
  if (hostRedirects) {
4212
4456
  add(hostRedirects.files);
4213
- if (hostRedirects.over_limit.length) {
4457
+ const overLimit = hostRedirects.over_limit.filter((from) => !hostOnly(from));
4458
+ const skippedPatterns = hostRedirects.skipped.filter((from) => !hostOnly(from));
4459
+ if (overLimit.length) {
4214
4460
  const limitedFiles = hostRedirects.written.filter((f) => !f.includes("(netlify)")).join(", ");
4215
- const feedOver = hostRedirects.over_limit.filter((from) => from in feedPlan.config).length;
4461
+ const feedOver = overLimit.filter((from) => from in feedPlan.config).length;
4216
4462
  const which = feedOver ? ` (${feedOver} of them feed redirects — /feed/ and archive feeds come after the site's own rules)` : "";
4217
- warnings.push(`redirects: ${hostRedirects.over_limit.length} rules not written to ${limitedFiles}${which} — ${HOST_RULE_LIMIT / 2} rules (each path with and without its slash) fill Cloudflare Pages' 2,000 static and Vercel's 2,048 redirect limit. They are served by the meta-refresh fallback only; move them to the host's dynamic rules for a real status`);
4463
+ warnings.push(`redirects: ${overLimit.length} rules not written to ${limitedFiles}${which} — ${HOST_RULE_LIMIT / 2} rules (each path with and without its slash) fill Cloudflare Pages' 2,000 static and Vercel's 2,048 redirect limit. They are served by the meta-refresh fallback only; move them to the host's dynamic rules for a real status`);
4218
4464
  }
4219
- if (hostRedirects.skipped.length) warnings.push(`redirects: ${hostRedirects.skipped.length} rules contain ":" or "*", which host redirect files read as patterns — served by the meta-refresh fallback only: ${hostRedirects.skipped.join(", ")}`);
4465
+ if (skippedPatterns.length) warnings.push(`redirects: ${skippedPatterns.length} rules contain ":" or "*", which host redirect files read as patterns — served by the meta-refresh fallback only: ${skippedPatterns.join(", ")}`);
4220
4466
  }
4221
4467
  if (input.options?.trailingSlash === false && (input.options.redirectHost ?? "vercel") === "vercel") {
4222
4468
  const vercel = files["vercel.json"] ? JSON.parse(files["vercel.json"]) : {};
@@ -4241,7 +4487,7 @@ function emitAstroProject(input) {
4241
4487
  const bodyMarkersByFamily = /* @__PURE__ */ new Map();
4242
4488
  for (const route of ir.routes) {
4243
4489
  if (route.kind !== "single" && route.collection === void 0) continue;
4244
- const posts = collectionItems(input.content ?? {}, route.collection ?? "posts");
4490
+ const posts = collectionItems(content, route.collection ?? "posts");
4245
4491
  const ids = bodyMarkersByFamily.get(route.family) ?? /* @__PURE__ */ new Set();
4246
4492
  for (const post of posts) for (const id of componentMarkers(post.body)) ids.add(id);
4247
4493
  if (ids.size) bodyMarkersByFamily.set(route.family, ids);
@@ -4250,7 +4496,7 @@ function emitAstroProject(input) {
4250
4496
  const queryBound = /* @__PURE__ */ new Set();
4251
4497
  for (const family of ir.families) for (const placement of family.components ?? []) {
4252
4498
  if (!placement.query) continue;
4253
- const pages = input.content?.queries?.[placement.query];
4499
+ const pages = content.queries?.[placement.query];
4254
4500
  if (!pages) {
4255
4501
  warnings.push(`family ${family.id}: placement "${placement.component}" binds query "${placement.query}", which is not in input.content.queries — binding dropped, region left as cloned markup`);
4256
4502
  continue;
@@ -4283,23 +4529,23 @@ function emitAstroProject(input) {
4283
4529
  for (const ref of refs ?? []) if (!providedCss.has(ref.split("/").pop() ?? ref)) warnings.push(`${owner}: css file "${ref}" not provided in input.css`);
4284
4530
  };
4285
4531
  for (const family of ir.families) missingCss(family.css.files, `family ${family.id}`);
4286
- const allPosts = [...input.content?.posts ?? [], ...Object.values(input.content?.collections ?? {}).flat()];
4532
+ const allPosts = [...content.posts ?? [], ...Object.values(content.collections ?? {}).flat()];
4287
4533
  for (const post of allPosts) missingCss(post.css, `post ${post.slug}`);
4288
- for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`);
4289
- const trailOwners = [...Object.entries(input.content?.collections ?? {}), ...input.content?.posts ? [[DEFAULT_COLLECTION, input.content.posts]] : []];
4534
+ for (const [queryId, queryPages] of Object.entries(content.queries ?? {})) for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`);
4535
+ const trailOwners = [...Object.entries(content.collections ?? {}), ...content.posts ? [[DEFAULT_COLLECTION, content.posts]] : []];
4290
4536
  for (const [name, posts] of trailOwners) {
4291
4537
  const bad = (posts ?? []).filter((p) => badTrail(p.breadcrumbs)).length;
4292
4538
  if (bad) warnings.push(`collection ${name}: ${bad} breadcrumb trails dropped — every crumb needs a name and a site-root-relative path`);
4293
4539
  const badLd = (posts ?? []).filter((p) => badSchema(p.schema)).length;
4294
4540
  if (badLd) warnings.push(`collection ${name}: ${badLd} schema values are not JSON-LD objects — those pages print the generated structured data instead`);
4295
4541
  }
4296
- for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) {
4542
+ for (const [queryId, queryPages] of Object.entries(content.queries ?? {})) {
4297
4543
  const bad = queryPages.filter((qp) => badTrail(qp.breadcrumbs)).length;
4298
4544
  if (bad) warnings.push(`query ${queryId}: ${bad} breadcrumb trails dropped — every crumb needs a name and a site-root-relative path`);
4299
4545
  const badLd = queryPages.filter((qp) => badSchema(qp.schema)).length;
4300
4546
  if (badLd) warnings.push(`query ${queryId}: ${badLd} schema values are not JSON-LD objects — those pages print the generated structured data instead`);
4301
4547
  }
4302
- let routeContent = input.content ?? {};
4548
+ let routeContent = content;
4303
4549
  if (seo) {
4304
4550
  const alternates = withAlternates(ir.routes, routeContent, lang);
4305
4551
  routeContent = alternates.content;
@@ -4354,8 +4600,8 @@ function emitAstroProject(input) {
4354
4600
  if (uncovered.length) warnings.push(`ui-strings: no dictionary ${declared ? "declared" : "declared in options.uiStrings.locales"} for ${uncovered.join(", ")} — comments and forms on those pages show English text unless ${stringsDir ?? ".contentrain/content/site/ui-strings"}/{locale}.json exists at build`);
4355
4601
  }
4356
4602
  if ((ir.components ?? []).some((c) => isRuntimeImplemented(c, input.runtime) && c.type === "comments")) {
4357
- const collections = { ...input.content?.collections };
4358
- if (input.content?.posts && !collections["posts"]) collections[DEFAULT_COLLECTION] = input.content.posts;
4603
+ const collections = { ...content.collections };
4604
+ if (content.posts && !collections["posts"]) collections[DEFAULT_COLLECTION] = content.posts;
4359
4605
  for (const [name, posts] of Object.entries(collections)) {
4360
4606
  const unbound = posts.filter((p) => !p.entry).length;
4361
4607
  if (unbound) warnings.push(`collection ${name}: ${unbound} of ${posts.length} posts carry no entry address — the comments component renders nothing on those pages`);
@@ -4381,11 +4627,19 @@ function emitAstroProject(input) {
4381
4627
  return {
4382
4628
  files,
4383
4629
  warnings,
4384
- ...redirectPlan ? { redirects: {
4385
- written: redirectPlan.written,
4386
- manual: redirectPlan.manual,
4630
+ ...redirectPlan || hostOnlyPlan ? { redirects: {
4631
+ written: [...redirectPlan?.written ?? [], ...[...hostOnlyRule.values()].filter((rule) => !unservedHostOnly.has(rule))],
4632
+ manual: [
4633
+ ...redirectPlan?.manual ?? [],
4634
+ ...hostOnlyManual,
4635
+ ...withheldManual
4636
+ ],
4387
4637
  host_files: hostRedirects?.written ?? [],
4388
- host_over_limit: hostRedirects?.over_limit ?? []
4638
+ host_over_limit: (hostRedirects?.over_limit ?? []).filter((from) => !hostOnly(from) || namedHost === void 0)
4639
+ } } : {},
4640
+ ...published.withheld.length || published.links.length ? { withheld: {
4641
+ entries: published.withheld,
4642
+ links: published.links
4389
4643
  } } : {}
4390
4644
  };
4391
4645
  }
@@ -4409,6 +4663,6 @@ async function writeEmit(result, dir) {
4409
4663
  await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
4410
4664
  }
4411
4665
  //#endregion
4412
- export { EMBED_TS, FEED_ITEMS, FEED_PATH, LLMS_LINKS, REDIRECT_STATUSES, RUNTIME_IMPLEMENTED, SEO_COMPONENT, UI_STRINGS_DIR, UI_STRINGS_MODEL, UI_STRING_DEFAULTS, astroRedirectsConfig, balanceWarning, bodySeoLeaks, builtAddresses, checkBalance, componentMarkers, emitAstroProject, entryPath, hostRedirectFiles, isRuntimeImplemented, linkSources, noindexPaths, pascalCase, patternToPagePath, planRedirects, publisherIdOf, rewriteFeedLinks, seoFromRawEntry, stableJson, stripSeoTags, websiteIdOf, withAlternates, wrapLegacyCss, writeEmit };
4666
+ export { EMBED_TS, FEED_ITEMS, FEED_PATH, LLMS_LINKS, REDIRECT_STATUSES, RUNTIME_IMPLEMENTED, SEO_COMPONENT, UI_STRINGS_DIR, UI_STRINGS_MODEL, UI_STRING_DEFAULTS, astroRedirectsConfig, balanceWarning, bodySeoLeaks, builtAddresses, checkBalance, componentMarkers, emitAstroProject, entryPath, holdReason, hostRedirectFiles, isRuntimeImplemented, linkSources, noindexPaths, pascalCase, patternToPagePath, planRedirects, publishedContent, publisherIdOf, rewriteFeedLinks, seoFromRawEntry, stableJson, stripSeoTags, websiteIdOf, withAlternates, wrapLegacyCss, writeEmit };
4413
4667
 
4414
4668
  //# sourceMappingURL=index.mjs.map