@aglyn/aglyn 1.0.0-beta.170 → 1.0.0-beta.172
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/package.json +10 -10
- package/src/lib/app-utils/content-authors.d.ts +17 -0
- package/src/lib/app-utils/content-authors.js +18 -0
- package/src/lib/app-utils/content-authors.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +2 -2
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/aglyn",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.172",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"./package.json": "./package.json"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-logger": "1.0.0-beta.
|
|
42
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
43
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
44
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
36
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.172",
|
|
37
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.172",
|
|
38
|
+
"@aglyn/shared-data-types": "1.0.0-beta.172",
|
|
39
|
+
"@aglyn/shared-util-email": "1.0.0-beta.172",
|
|
40
|
+
"@aglyn/shared-util-http": "1.0.0-beta.172",
|
|
41
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.172",
|
|
42
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.172",
|
|
43
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.172",
|
|
44
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.172",
|
|
45
45
|
"@data-driven-forms/react-form-renderer": "^4.2.0",
|
|
46
46
|
"@msgpack/msgpack": "^3.1.3",
|
|
47
47
|
"@types/unist": "^3.0.3",
|
|
@@ -555,6 +555,23 @@ export declare function resolveEntryAuthor(entry: AuthorBearingEntry | null | un
|
|
|
555
555
|
* rather than the word "undefined".
|
|
556
556
|
*/
|
|
557
557
|
export declare function resolveEntryAuthorName(entry: AuthorBearingEntry | null | undefined, authors?: readonly ContentAuthorRecord[] | null): string;
|
|
558
|
+
/**
|
|
559
|
+
* Does the entry name a byline of its OWN — an author record or a custom
|
|
560
|
+
* byline — rather than falling back to the site?
|
|
561
|
+
*
|
|
562
|
+
* A byline is required to publish or schedule an entry. The site fallback in
|
|
563
|
+
* {@link resolveEntryAuthor} stays for what is already stored, but a post that
|
|
564
|
+
* goes live from here on must say who wrote it: an unattributed article reads
|
|
565
|
+
* as unowned to a visitor, and Entry Meta prints no name at all for it.
|
|
566
|
+
*
|
|
567
|
+
* Asks what is STORED, not whether it resolves. An `authorId` whose record was
|
|
568
|
+
* deleted still carries its denormalized `authorName` and still renders a
|
|
569
|
+
* name, so refusing it would block unpublishing-and-republishing a post that
|
|
570
|
+
* reads correctly today.
|
|
571
|
+
*/
|
|
572
|
+
export declare function entryHasByline(entry: AuthorBearingEntry | null | undefined): boolean;
|
|
573
|
+
/** The refusal a publish or schedule without a byline shows. */
|
|
574
|
+
export declare const ENTRY_BYLINE_REQUIRED_MESSAGE = "Add an author before publishing \u2014 pick one or type a custom byline";
|
|
558
575
|
/** The site fields {@link siteEntityJsonLd} falls back to. */
|
|
559
576
|
export interface SiteEntityHost {
|
|
560
577
|
$id?: string;
|
|
@@ -592,6 +592,24 @@ const text = (value, max = AUTHOR_NAME_MAX_LENGTH)=>typeof value === 'string' ?
|
|
|
592
592
|
var _resolveEntryAuthor;
|
|
593
593
|
return (_ref = (_resolveEntryAuthor = resolveEntryAuthor(entry, authors)) == null ? void 0 : _resolveEntryAuthor.name) != null ? _ref : '';
|
|
594
594
|
}
|
|
595
|
+
/**
|
|
596
|
+
* Does the entry name a byline of its OWN — an author record or a custom
|
|
597
|
+
* byline — rather than falling back to the site?
|
|
598
|
+
*
|
|
599
|
+
* A byline is required to publish or schedule an entry. The site fallback in
|
|
600
|
+
* {@link resolveEntryAuthor} stays for what is already stored, but a post that
|
|
601
|
+
* goes live from here on must say who wrote it: an unattributed article reads
|
|
602
|
+
* as unowned to a visitor, and Entry Meta prints no name at all for it.
|
|
603
|
+
*
|
|
604
|
+
* Asks what is STORED, not whether it resolves. An `authorId` whose record was
|
|
605
|
+
* deleted still carries its denormalized `authorName` and still renders a
|
|
606
|
+
* name, so refusing it would block unpublishing-and-republishing a post that
|
|
607
|
+
* reads correctly today.
|
|
608
|
+
*/ export function entryHasByline(entry) {
|
|
609
|
+
var _ref, _ref1;
|
|
610
|
+
return Boolean(((_ref = entry == null ? void 0 : entry.authorId) != null ? _ref : '').trim() || ((_ref1 = entry == null ? void 0 : entry.authorName) != null ? _ref1 : '').trim());
|
|
611
|
+
}
|
|
612
|
+
/** The refusal a publish or schedule without a byline shows. */ export const ENTRY_BYLINE_REQUIRED_MESSAGE = 'Add an author before publishing — pick one or type a custom byline';
|
|
595
613
|
/** The fragment identifier the site entity is published under. */ export const SITE_ENTITY_FRAGMENT = '#organization';
|
|
596
614
|
/**
|
|
597
615
|
* EVERY PROFILE THE SITE CLAIMS AS ITSELF — `schema.org` `sameAs` (AGL-3148).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/content-authors.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HostEntityType } from '../foundation/definitions/platform.types'\nimport { MEDIA_ALT_MAX_LENGTH } from './media-metadata'\nimport { absoluteMediaSrc } from './media-ref'\nimport { urlSlugSegment } from './url-slug'\n\n/**\n * Custom content authors (AGL-2486).\n *\n * ## An author is a RECORD, not an account\n *\n * Until now a post's byline was `entry.authorName` — one free-typed string\n * (AGL-686), retyped on every entry, with no url, no portrait, no social\n * profiles and no way to say the piece was written by the company rather than\n * a person. That is enough to print a name under a headline and not enough to\n * be `schema.org/author`, which wants an ENTITY: something with a stable\n * identity a crawler can join across articles and against the site's own\n * publisher entity.\n *\n * So the author becomes a document the customer authors and an entry\n * REFERENCES (`entry.authorId`). Publishing a piece under a pen name, a guest\n * contributor, or the company itself is then the ordinary case rather than a\n * retyped string, and renaming an author updates every post at render time —\n * the same property `categoryId` bought the taxonomy in AGL-582.\n *\n * ## Scope: the HOST, at `hosts/{hostId}/authors/{authorId}`\n *\n * Not the org, and this follows the data rather than inventing a rule.\n * Content collections live at `hosts/{hostId}/collections/{collectionId}` and\n * their entries underneath; the console surface that manages them is\n * `/[orgSlug]/hosts/[host]/content`; and the entity an author has to\n * serialise consistently with — `host.seo.entity`, the site's publisher — is\n * a field of the host document. An author referenced from an entry therefore\n * resolves inside the same document tree the entry already lives in, with the\n * host's existing rules and the tenant's existing Admin-SDK reads. An\n * org-scoped store would need a second root, a second read on every entry\n * render, and a sharing model (\"which sites may publish under this byline\")\n * that nothing in the product asks for yet.\n *\n * ## The type is a BRANCH, not a label\n *\n * `schema.org/Person` and `schema.org/Organization` do not carry the same\n * fields: a Person has `jobTitle` and `worksFor` and its portrait is `image`;\n * an Organization has no job title and its mark is `logo`. Emitting one\n * shape with the other's keys is invalid structured data that validates as\n * \"ignored\", so {@link contentAuthorJsonLd} branches on the type and the\n * console hides the fields that do not apply.\n */\n\n/**\n * How many authors one host may hold (AGL-2486).\n *\n * A flat PLATFORM cap, deliberately, in the shape of\n * `ENTRIES_MAX_PER_COLLECTION` / `ACTIONS_MAX_PER_HOST` (AGL-2266) and\n * `WEBHOOK_MAX_PER_HOST` (AGL-1360): nothing here is priced, no\n * `OrgEntitlements` key is added, every plan gets the same number. The reason\n * a number exists at all is the one those issues wrote down — a\n * client-creatable host subcollection with no cap is unbounded Firestore\n * documents mintable from the browser against a $0 subscription — and adding\n * a new subcollection without one would be that hole, freshly dug.\n *\n * 200 is far past any real masthead (the largest content collection in\n * production holds double digits of ENTRIES, let alone bylines) and small\n * enough that the console can list every author in a single unpaginated read.\n */\nexport const AUTHORS_MAX_PER_HOST = 200\n\n/** Longest stored author name; the byline is a name, not a paragraph. */\nexport const AUTHOR_NAME_MAX_LENGTH = 120\n\n/** Longest stored author slug; it is one path segment, not a sentence. */\nexport const AUTHOR_SLUG_MAX_LENGTH = 80\n\n/**\n * The site-wide segment an author's page lives under (AGL-2518):\n * `/author/{slug}`, and `/author/{slug}/page/{n}` after it.\n *\n * Singular, and site-wide rather than nested under a collection. AGL-2517\n * put this archive at `/{collection}/author/{slug}`, which gives one person\n * as many pages as the site has collections — three partial archives of the\n * same author, none of them the address a byline should link to, all three\n * competing for the same search result. A person is not a property of a\n * collection.\n *\n * NOT added to `RESERVED_SCREEN_ROUTE_SEGMENTS`. The catch-all resolves\n * published screens BEFORE it reaches this route, exactly as it does for a\n * collection's own `/{slug}`, so a site that already publishes a page at\n * `/author` keeps it and simply has no author pages — the same trade every\n * other platform-built address on a tenant site makes.\n */\nexport const AUTHOR_ROUTE_SEGMENT = 'author'\n\n/** Most `sameAs` profile links one author may carry. */\nexport const AUTHOR_SAME_AS_MAX = 12\n\n/** Most display links one author may carry. */\nexport const AUTHOR_LINKS_MAX = 12\n\n/**\n * A link the author profile PRINTS, as opposed to one it only declares\n * (AGL-2516).\n *\n * `sameAs` already held profile URLs, and it is exactly the wrong shape to\n * render: a bare list of strings with no label and no icon, which a card can\n * only draw as a row of identical anonymous links. It exists for crawlers,\n * where a URL alone is the whole point.\n *\n * A link somebody CLICKS needs to say what it is before they click it, and\n * the two ways of saying that pull in opposite directions:\n *\n * * a KNOWN platform is recognised by its mark, and the mark is not the\n * author's to choose — an X link drawn with a GitHub glyph is a broken\n * link that still resolves, and letting anyone pick that is a footgun with\n * no upside. So {@link platform} fixes the icon AND the accessible name;\n * * anything else — a personal newsletter, a conference talk, an ORCID\n * record — has no mark anyone would recognise, so it takes a label and an\n * icon the author chooses.\n *\n * One field decides which of the two a row is, and the console shows the\n * fields that apply to it. See {@link AUTHOR_SOCIAL_PLATFORMS}.\n */\nexport interface ContentAuthorLink {\n /**\n * A known platform id from {@link AUTHOR_SOCIAL_PLATFORMS}, whose icon and\n * accessible name are FIXED. Empty (or unrecognised) makes this a custom\n * link, which is where {@link label} and {@link icon} apply instead.\n */\n platform?: string\n /** Custom links only — what this link is called. */\n label?: string\n /** Custom links only — the picked `mdi` icon id. */\n icon?: string\n /**\n * Custom links only — the picked icon's PATH, resolved where the catalog\n * is already loaded.\n *\n * The same split AGL-1212 made for besigner icons, for the same reason: the\n * mdi catalog is ~2.9 MB and only picker surfaces load it, so a renderer\n * given an id alone can only draw a fallback glyph. The id stays the source\n * of truth; the path travels with the document so the tenant never pays for\n * the catalog to draw one icon.\n */\n iconPath?: string\n /** Where it goes. `https:` or `mailto:` only — see the normalizer. */\n url?: string\n}\n\n/**\n * The platforms whose mark is fixed (AGL-2516).\n *\n * `icon` is an `mdi` id, resolved to a path by whichever surface draws it —\n * the renderer imports these few directly rather than through the catalog,\n * because the set is closed and known at build time.\n *\n * Ordered as a masthead would list them rather than alphabetically: the\n * places writers are actually followed first, then the ways to reach them.\n */\nexport const AUTHOR_SOCIAL_PLATFORMS: ReadonlyArray<{\n id: string\n label: string\n icon: string\n}> = [\n { id: 'x', label: 'X', icon: 'twitter' },\n { id: 'linkedin', label: 'LinkedIn', icon: 'linkedin' },\n { id: 'github', label: 'GitHub', icon: 'github' },\n { id: 'mastodon', label: 'Mastodon', icon: 'mastodon' },\n { id: 'youtube', label: 'YouTube', icon: 'youtube' },\n { id: 'instagram', label: 'Instagram', icon: 'instagram' },\n { id: 'facebook', label: 'Facebook', icon: 'facebook' },\n { id: 'website', label: 'Website', icon: 'web' },\n { id: 'email', label: 'Email', icon: 'email' },\n { id: 'rss', label: 'RSS', icon: 'rss' },\n]\n\n/** The platform a link declares, or `undefined` when it is a custom one. */\nexport function authorLinkPlatform(\n platform: string | undefined | null,\n): (typeof AUTHOR_SOCIAL_PLATFORMS)[number] | undefined {\n const id = typeof platform === 'string' ? platform.trim() : ''\n if (!id) return undefined\n return AUTHOR_SOCIAL_PLATFORMS.find((entry) => entry.id === id)\n}\n\n/**\n * What a link is CALLED, which is never the author's choice on a known\n * platform.\n *\n * A custom link with no label falls back to its own URL rather than to\n * something generic: \"Link\" beside four other \"Link\"s is not an accessible\n * name, and the URL at least distinguishes them.\n */\nexport function authorLinkLabel(link: ContentAuthorLink): string {\n const known = authorLinkPlatform(link.platform)\n if (known) return known.label\n return text(link.label, 60) || text(link.url, 400)\n}\n\n/**\n * Schemes an author link may use.\n *\n * `https:` and `mailto:` only, matching the share bar and the Entry Author\n * card's own href guard. `http:` is excluded deliberately — a byline link is\n * published on every post its author wrote, and a mixed-content warning\n * across a whole archive is a poor trade for a scheme nobody should still be\n * publishing. Everything else, `javascript:` first among them, is a script\n * injection wearing a URL.\n */\nconst SAFE_AUTHOR_LINK_HREF = /^(https:\\/\\/|mailto:)/i\n\n/**\n * Sanitize the stored link rows into the shape a renderer may assume.\n *\n * Drops any row without a usable, safely-schemed URL — a link that goes\n * nowhere is worse than an absent one, because it still takes a click. A row\n * naming a platform keeps ONLY the platform (its label and icon come from the\n * registry, so a stored label or icon beside one is stale data that must not\n * win); a custom row keeps its label and icon and drops any platform id that\n * did not resolve.\n */\nexport function normalizeContentAuthorLinks(\n value: unknown,\n): ContentAuthorLink[] {\n if (!Array.isArray(value)) return []\n const links: ContentAuthorLink[] = []\n for (const row of value as unknown[]) {\n if (!row || typeof row !== 'object') continue\n const raw = row as Record<string, unknown>\n const url = text(raw['url'], 400)\n if (!url || !SAFE_AUTHOR_LINK_HREF.test(url)) continue\n const known = authorLinkPlatform(raw['platform'] as never)\n if (known) {\n links.push({ platform: known.id, url })\n } else {\n links.push({\n label: text(raw['label'], 60),\n icon: text(raw['icon'], 120),\n iconPath: text(raw['iconPath'], 4000),\n url,\n })\n }\n if (links.length >= AUTHOR_LINKS_MAX) break\n }\n return links\n}\n\n/**\n * One custom author, as stored at `hosts/{hostId}/authors/{authorId}`.\n *\n * Every field is optional at the type level because `strictNullChecks` is off\n * repo-wide and because a document written by an older console must keep\n * reading — {@link normalizeContentAuthor} is what turns a stored shape into\n * something safe to serialise, and it is the only thing that should.\n */\nexport interface ContentAuthorRecord {\n $id?: string\n /**\n * `Person` or `Organization` — the SAME enum the site's SEO entity uses\n * (`host.seo.entity.type`), so the two serialise through one branch rather\n * than two spellings of the same question.\n *\n * Stored as the numeric enum by this feature's editor. Read through\n * {@link contentAuthorSchemaType}, which also accepts the STRING form,\n * because the Setup → SEO → Entity form has always written `\"1\"` / `\"2\"`\n * (its Select options are template literals) — see that helper.\n */\n type?: HostEntityType | string | number\n /** The byline. The only field an author cannot be published without. */\n name?: string\n /**\n * The segment that addresses this author's page — `/author/{slug}`\n * (AGL-2518).\n *\n * Optional, and {@link contentAuthorSlug} falls back to the slugified name,\n * so an author who never opens the field still has a working, readable\n * address. It exists for the two cases a derived slug cannot serve:\n *\n * * a RENAME. The name is the byline and it changes — a married name, a\n * pen name, a company rebrand — and a derived slug moves the page with\n * it, breaking every link anyone ever shared. A stored slug is the\n * author saying \"this address is mine regardless of what you call me\";\n * * a COLLISION. Two people named Chris Taylor derive one segment, and the\n * archive would then hold both their work under one page with no way to\n * separate it.\n *\n * Matching still accepts the record id and the slugified display name (see\n * `entryMatchesAuthorRoute`), so setting this never breaks an address that\n * already worked — it only adds one.\n */\n slug?: string\n /** Author page / personal site — `schema.org` `url`. */\n url?: string\n /**\n * Portrait or logo. A `media:` reference (what the console's media picker\n * writes) or a plain URL — an external avatar is a legitimate answer, so\n * the field accepts both and {@link contentAuthorJsonLd} resolves whichever\n * arrives through the same helper `og:image` uses.\n *\n * Emitted as `image` for a Person and `logo` for an Organization.\n */\n image?: string\n /** Person only — `schema.org/Person.jobTitle`. */\n jobTitle?: string\n /** Person only — the organization they write for (`worksFor.name`). */\n worksFor?: string\n /**\n * Profile URLs (social, ORCID, Crunchbase…) — `schema.org` `sameAs`.\n *\n * Declared, never drawn. {@link links} is the rendered list; every link\n * there is folded into the emitted `sameAs` too, so an author who fills in\n * the visible row does not also have to retype the URL for the crawler.\n */\n sameAs?: string[]\n /** The links the author profile PRINTS (AGL-2516). */\n links?: ContentAuthorLink[]\n /**\n * Byline blurb for the page. NOT structured data: `description` on a Person\n * is a claim about the person, and a marketing sentence is not that.\n */\n bio?: string\n /**\n * The `<meta name=\"description\">` for this author's page, overriding\n * {@link bio}.\n *\n * The two are different sentences written for different readers, which is\n * why one field cannot serve both. A bio is printed BESIDE the byline, on a\n * page whose heading already says whose it is, and it runs as long as the\n * person wants; a meta description is read alone in a search result, has to\n * name the person it is about, and is cut off past roughly 160 characters.\n * Falling back to the bio is still right — a long true sentence beats no\n * description at all — but an author who wants the snippet to read a\n * particular way now has somewhere to say so.\n */\n seoDescription?: string\n /**\n * The share card for this author's page, overriding {@link image}.\n *\n * `image` is a PORTRAIT: square, cropped to a face, and correct as the\n * `schema.org` image of a person. A share card is 1200×630 and gets\n * letterboxed or center-cropped when a square goes out in its place, which\n * is why the author page ships the small `summary` card while a page with\n * one of these ships the large one.\n *\n * A `media:` reference or a plain URL, resolved through the same\n * `resolveSocialImage` every other surface's card goes through.\n */\n seoImage?: string\n /**\n * `og:image:alt` for {@link seoImage} — what the card SHOWS, for the screen\n * readers that announce a social preview.\n *\n * Stored beside the reference rather than derived, and read only when\n * `seoImage` is the image that won: a description carried over to the\n * portrait would describe a picture the card does not show.\n */\n seoImageAlt?: string\n}\n\n/**\n * The author's schema type, tolerant of every spelling the store holds.\n *\n * The numeric enum is what `HostEntityType` declares, but the Setup → SEO →\n * Entity form persists its Select value — a TEMPLATE LITERAL, i.e. the string\n * `\"1\"` or `\"2\"` — and the tenant's structured data compared that with\n * `=== HostEntityType.PERSON` (the number `2`). Strict equality across a\n * string and a number is always false, so a site that declared itself a\n * Person published `\"@type\": \"Organization\"` regardless (AGL-2486). Coercing\n * here, once, is what stops the same defect being re-derived per call site.\n *\n * Anything unrecognised — absent, empty, a stray value — resolves to\n * `Organization`, which is the default this repo has always emitted.\n */\nexport function contentAuthorSchemaType(\n type: HostEntityType | string | number | undefined | null,\n): 'Person' | 'Organization' {\n return Number(type) === HostEntityType.PERSON ? 'Person' : 'Organization'\n}\n\nconst text = (value: unknown, max = AUTHOR_NAME_MAX_LENGTH): string =>\n typeof value === 'string' ? value.trim().slice(0, max) : ''\n\n/**\n * A stored author document, sanitized into the shape the rest of the codebase\n * may assume — the {@link mapCollectionCategories} treatment, one collection\n * over: only strings survive, `sameAs` keeps string entries only, and an\n * author with no name resolves to `null` because a nameless byline is not a\n * byline and `schema.org` requires `name` on both branches.\n */\nexport function normalizeContentAuthor(\n value: unknown,\n id?: string,\n): ContentAuthorRecord | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const name = text(raw['name'])\n if (!name) return null\n const sameAs = Array.isArray(raw['sameAs'])\n ? (raw['sameAs'] as unknown[])\n .map((item) => text(item, 400))\n .filter(Boolean)\n .slice(0, AUTHOR_SAME_AS_MAX)\n : []\n return {\n ...(id ? { $id: id } : typeof raw['$id'] === 'string' ? { $id: raw['$id'] } : {}),\n type: contentAuthorSchemaType(raw['type'] as never) === 'Person'\n ? HostEntityType.PERSON\n : HostEntityType.ORGANIZATION,\n name,\n // Through the URL slugifier rather than stored raw: this is a path\n // segment, and a stored `Chris Taylor` would build `/author/Chris Taylor`\n // while every incoming request arrived slugified and matched nothing.\n slug: urlSlugSegment(text(raw['slug'], AUTHOR_SLUG_MAX_LENGTH)),\n url: text(raw['url'], 400),\n image: text(raw['image'], 1000),\n jobTitle: text(raw['jobTitle']),\n worksFor: text(raw['worksFor']),\n sameAs,\n links: normalizeContentAuthorLinks(raw['links']),\n bio: text(raw['bio'], 600),\n // Shorter than the bio it overrides, because it is read alone in a search\n // result rather than beside a heading — past this nothing survives the\n // snippet, so storing more only hides the cut from whoever wrote it.\n seoDescription: text(raw['seoDescription'], 300),\n seoImage: text(raw['seoImage'], 1000),\n seoImageAlt: text(raw['seoImageAlt'], MEDIA_ALT_MAX_LENGTH),\n }\n}\n\n/* ── The author's page (AGL-2518) ───────────────────────────────────────── */\n\n/** How a caller names the author whose page it wants addressed. */\nexport interface ContentAuthorAddress {\n /** The resolved record, when there is one. */\n author?: ContentAuthorRecord | null\n /** `entry.authorId` — a reference that may not have resolved. */\n authorId?: string\n /** The legacy free-typed byline (AGL-686). */\n authorName?: string\n}\n\n/**\n * The segment that addresses one author's page.\n *\n * Precedence is the author's stored {@link ContentAuthorRecord.slug} first\n * and the slugified name second — the opposite of what AGL-2517 built, which\n * put the record ID first \"so the link survives a rename\".\n *\n * A Firestore auto-id does survive a rename, and it is also unreadable. This\n * is a PUBLIC address on a marketing site: it goes in a byline, a share\n * sheet, a CV. `/author/hT3kQ9xLmZ2` is not an address anyone can read, and\n * the rename it protects against is answered better by the stored slug, which\n * is stable AND readable. The id is still ACCEPTED on the way in (see\n * `entryMatchesAuthorRoute`), so a link built under the old precedence keeps\n * resolving.\n *\n * Empty when there is nothing addressable, so a caller renders plain text\n * rather than a link to `/author/`.\n */\nexport function contentAuthorSlug(options: ContentAuthorAddress): string {\n return (\n urlSlugSegment(options.author?.slug) ||\n urlSlugSegment(options.author?.name) ||\n urlSlugSegment(options.authorName) ||\n urlSlugSegment(options.authorId) ||\n urlSlugSegment(options.author?.$id)\n )\n}\n\n/**\n * Every segment that should resolve to this author.\n *\n * The archive answers to the stored slug, the display name, the legacy\n * free-typed byline and the record id, because which of those a link happens\n * to carry depends on when it was built and by what — and a byline that\n * 404s is worse than one pointing at a page with an unexpected URL.\n *\n * Deduped, and empties dropped: a set containing `''` would match a request\n * for `/author/` against every author on the site.\n */\nexport function contentAuthorSlugCandidates(\n options: ContentAuthorAddress,\n): string[] {\n return Array.from(\n new Set(\n [\n options.author?.slug,\n options.author?.name,\n options.authorName,\n options.authorId,\n options.author?.$id,\n ]\n .map((value) => urlSlugSegment(value))\n .filter(Boolean),\n ),\n )\n}\n\n/** Does `slug` address this author? */\nexport function contentAuthorMatchesSlug(\n options: ContentAuthorAddress,\n slug: string | undefined | null,\n): boolean {\n const wanted = urlSlugSegment(slug)\n if (!wanted) return false\n return contentAuthorSlugCandidates(options).includes(wanted)\n}\n\n/**\n * The author's page on this site — `/author/{slug}` (AGL-2518).\n *\n * Empty when nothing addresses the author, which is what lets a template bind\n * this unconditionally: a link whose href does not resolve renders as inert\n * markup of the same element (AGL-1268/1357), so an entry with no byline\n * shows no link rather than one pointing nowhere.\n */\nexport function contentAuthorPageUrl(options: ContentAuthorAddress): string {\n const slug = contentAuthorSlug(options)\n return slug ? `/${AUTHOR_ROUTE_SEGMENT}/${slug}` : ''\n}\n\n/**\n * The paginated sub-path of an author page — `/author/{slug}/page/{n}`.\n *\n * The same word the collection listing pages with, deliberately: a reader who\n * has learned one URL shape on the site has learned both, and a second\n * spelling would buy nothing. Declared here rather than imported from\n * `collection-entries`, which imports THIS module.\n */\nconst AUTHOR_PAGE_SEGMENT = 'page'\n\n/**\n * The canonical URL of one page of an author's archive (AGL-2518).\n *\n * Page 1 is the bare `/author/{slug}` — there is no `/author/{slug}/page/1`,\n * so the first page cannot become a second address for itself. The rule\n * `collectionListUrl` states, one route over.\n */\nexport function contentAuthorPageAtUrl(\n options: ContentAuthorAddress & { page?: number | null },\n): string {\n const base = contentAuthorPageUrl(options)\n if (!base) return ''\n const page = Number(options.page)\n return Number.isFinite(page) && page > 1\n ? `${base}/${AUTHOR_PAGE_SEGMENT}/${Math.floor(page)}`\n : base\n}\n\n/**\n * Where an author archive's pager can go from here (AGL-2518).\n *\n * The `collectionPaginationLinks` contract, restated for this route: **the\n * edges resolve to the empty string, never to a URL**, which is what lets a\n * designed author template bind `{{pagination.prevUrl}}` unconditionally. A\n * link whose href is `''` renders as an inert placeholder of the same element\n * (AGL-1268/1357), which is the correct pager on page 1 of 1.\n *\n * ONE computation, because it has the same two consumers that must agree: the\n * built-in fallback's pager nodes and the tokens a template binds.\n */\nexport function contentAuthorPaginationLinks(\n options: ContentAuthorAddress & {\n page?: number | null\n totalPages?: number | null\n },\n): { page: number; totalPages: number; prevUrl: string; nextUrl: string } {\n const positive = (value: number | null | undefined): number => {\n const parsed = Math.floor(Number(value))\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 1\n }\n const page = positive(options.page)\n const totalPages = positive(options.totalPages)\n const href = (n: number) => contentAuthorPageAtUrl({ ...options, page: n })\n return {\n page,\n totalPages,\n prevUrl: page > 1 ? href(page - 1) : '',\n nextUrl: page < totalPages ? href(page + 1) : '',\n }\n}\n\n/** A parsed `/author/{slug}` route (AGL-2518). */\nexport interface ContentAuthorRoute {\n /** The addressed segment, normalized. */\n authorSlug: string\n /** 1-based list page; 1 on the bare archive. */\n page: number\n}\n\n/**\n * Parse a path into an author route, or `null` when the path is not one.\n *\n * The two shapes are `/author/{slug}` and `/author/{slug}/page/{n}`, matching\n * the collection listing's own pagination so a reader who has learned one URL\n * shape on the site has learned both.\n *\n * A pure parser, deliberately, for the reason `parseCollectionRoute` is one:\n * the tenant loader and its tests must not be able to disagree about what the\n * route table says. Page `0`, a negative page and a non-numeric page all\n * resolve to `null` rather than to page 1, so a nonsense URL 404s instead of\n * silently serving the first page under a second address.\n */\nexport function parseContentAuthorRoute(\n segments: readonly string[],\n): ContentAuthorRoute | null {\n const parts = (segments ?? []).filter(Boolean)\n if (parts[0] !== AUTHOR_ROUTE_SEGMENT) return null\n const authorSlug = urlSlugSegment(parts[1])\n if (!authorSlug) return null\n if (parts.length === 2) return { authorSlug, page: 1 }\n if (parts.length === 4 && parts[2] === AUTHOR_PAGE_SEGMENT) {\n const page = Number(parts[3])\n return Number.isInteger(page) && page >= 1 ? { authorSlug, page } : null\n }\n return null\n}\n\n/** How an author's `image` is turned into something a crawler can fetch. */\nexport interface ContentAuthorImageContext {\n /** The site's public origin, for absolutizing a site-relative CDN path. */\n origin?: string | null\n /** Host id, so a restricted org asset resolves for the site rendering it. */\n hostId?: string\n}\n\n/**\n * `schema.org` JSON-LD for one author (AGL-2486) — a `Person` or an\n * `Organization`, with the fields that branch actually defines.\n *\n * | field | Person | Organization |\n * | ---------- | ------------------------- | ------------ |\n * | `name` | required | required |\n * | `url` | optional | optional |\n * | portrait | `image` | `logo` |\n * | `jobTitle` | optional | — not valid |\n * | `worksFor` | optional (`Organization`) | — not valid |\n * | `sameAs` | optional | optional |\n *\n * The image goes through {@link absoluteMediaSrc}, the same resolver\n * `og:image` and `Article.image` use (AGL-1343/1407), so a portrait picked\n * from the DAM reaches the structured data as a fetchable absolute URL rather\n * than the literal string `media:{scope}/{id}`. An unresolvable reference —\n * or a site-relative path with no origin to resolve against — emits NO image\n * key at all rather than a broken one.\n *\n * Returns `undefined` for an author with no usable name, so every call site\n * can spread the result conditionally instead of emitting `\"author\": null`.\n *\n * ## Escaping\n *\n * Nothing is escaped HERE, and that is correct: this returns a VALUE, and the\n * one place a value becomes markup is `Aglyn.safeJsonLd`, which every\n * `<script type=\"application/ld+json\">` in the repo is built with. It escapes\n * `<`, `>`, `&` and U+2028/U+2029 to their `\\uXXXX` forms after\n * `JSON.stringify`, so an author named `</script><img onerror=…>` serialises\n * as inert text rather than closing the element (AGL-496). Escaping a second\n * time here would double-encode and put literal backslashes in a byline.\n */\nexport function contentAuthorJsonLd(\n author: ContentAuthorRecord | null | undefined,\n context?: ContentAuthorImageContext,\n): Record<string, unknown> | undefined {\n const normalized = normalizeContentAuthor(author, author?.$id)\n if (!normalized) return undefined\n const schemaType = contentAuthorSchemaType(normalized.type)\n const image = absoluteMediaSrc(normalized.image, {\n hostId: context?.hostId,\n origin: context?.origin,\n })\n const sameAs = Array.from(\n new Set([\n ...(normalized.sameAs ?? []),\n ...(normalized.links ?? [])\n .map((link) => link.url ?? '')\n .filter((url) => /^https:\\/\\//i.test(url)),\n ]),\n ).slice(0, AUTHOR_SAME_AS_MAX)\n return {\n '@type': schemaType,\n name: normalized.name,\n ...(normalized.url ? { url: normalized.url } : {}),\n // `image` on a Person, `logo` on an Organization — the one field whose\n // KEY the branch changes, which is why a shared \"entity\" serializer that\n // took a type parameter and emitted `logo` for both would be wrong.\n ...(image\n ? schemaType === 'Person'\n ? { image }\n : { logo: image }\n : {}),\n ...(schemaType === 'Person' && normalized.jobTitle\n ? { jobTitle: normalized.jobTitle }\n : {}),\n ...(schemaType === 'Person' && normalized.worksFor\n ? { worksFor: { '@type': 'Organization', name: normalized.worksFor } }\n : {}),\n // A link the profile PRINTS is also a profile the author claims, so the\n // rendered rows join `sameAs` rather than being a second list to keep in\n // step by hand (AGL-2516). `mailto:` is excluded: `sameAs` is for pages\n // that identify the same entity, and an address is not one.\n ...(sameAs.length ? { sameAs } : {}),\n }\n}\n\n/** The site's own publisher entity, as `host.seo.entity` stores it. */\nexport interface HostSeoEntity {\n type?: HostEntityType | string | number\n name?: string\n logo?: string\n /** What the publisher IS, in a sentence (AGL-2716). */\n description?: string\n /** The publisher's canonical address, when it is not this site. */\n url?: string\n /** Profiles that identify the same entity. */\n sameAs?: string[]\n /** Contact details, assembled into a `contactPoint` at serialization. */\n email?: string\n telephone?: string\n contactType?: string\n /** Postal address, emitted as a `PostalAddress`. */\n address?: {\n streetAddress?: string\n addressLocality?: string\n addressRegion?: string\n postalCode?: string\n addressCountry?: string\n }\n}\n\n/**\n * `schema.org` JSON-LD for the SITE entity (`host.seo.entity`) — the value\n * `Article.publisher` and `WebSite.publisher` are built from.\n *\n * Lives beside {@link contentAuthorJsonLd} on purpose (AGL-2486): a site\n * entity and a post author answer the same question about two different\n * subjects, and the reason the Person branch was broken for two years is that\n * the answer was written inline at its one call site where nothing could\n * test it. One module, two entry points, one `Person`/`Organization`\n * decision — {@link contentAuthorSchemaType}.\n *\n * `logo` is emitted verbatim on BOTH branches, unchanged from what the tenant\n * has always published. It is `host.seo.entity.logo`, a plain URL field on\n * the Setup form rather than a media-picker target, and re-keying it to\n * `image` for a Person would change the publisher block on every page of\n * every site that set one — a separate decision from this one.\n */\nexport function hostSeoEntityJsonLd(\n entity: HostSeoEntity | null | undefined,\n): Record<string, unknown> | undefined {\n const name = text(entity?.name, 400)\n if (!name) return undefined\n return {\n '@type': contentAuthorSchemaType(entity?.type),\n name,\n }\n}\n\n/**\n * The publisher's picture, under the property its TYPE actually has\n * (AGL-2486).\n *\n * `schema.org` gives `logo` to an Organization and not to a Person; a Person\n * carries `image`. The tenant emitted `logo` for both, so a site that declared\n * itself a Person published a property its own `@type` does not define —\n * ignored by every consumer, which is the worst kind of wrong: the field is\n * set, the console says it is published, and nothing renders it.\n *\n * That is the same shape as the bug one function up, where a string `\"2\"` was\n * compared with a numeric enum and no site could ever publish `Person` at all.\n * Both resolve here so the type is decided in exactly one place.\n *\n * Returns a spreadable object so callers stay a spread rather than a ternary:\n * `{...hostSeoEntityImageJsonLd(entity)}` adds nothing when there is no\n * picture, which is what an absent property should cost.\n */\nexport function hostSeoEntityImageJsonLd(\n entity: HostSeoEntity | null | undefined,\n context?: ContentAuthorImageContext,\n): Record<string, string> {\n /*\n Through `absoluteMediaSrc`, exactly as an author's portrait goes\n (AGL-1343/1407). The stored value has three generations — a raw storage\n URL, a site-relative CDN path, and a `media:` reference — and the site\n entity's was the one path still emitting whichever it found. A `media:`\n reference reaches Google as the literal string `media:{scope}/{id}`,\n which does not fetch; a site-relative path with no origin does not either.\n\n An unresolvable value emits NO key rather than a broken one — the same\n rule the author serializer states, and the reason both return a spreadable\n object instead of a URL.\n */\n /*\n TRIMMED first. `absoluteMediaSrc` does not trim, and a whitespace-only\n value — what a field cleared by typing spaces leaves behind — is not\n empty to it: it reads as a relative path and comes back joined to the\n origin, so the site would publish `https://example.com/ ` as its\n publisher logo. Trimmed here rather than in the resolver, which og:image\n and every author portrait also go through.\n */\n const stored = typeof entity?.logo === 'string' ? entity.logo.trim() : ''\n const image = stored\n ? absoluteMediaSrc(stored, {\n hostId: context?.hostId,\n origin: context?.origin,\n })\n : ''\n if (!image) return {}\n return contentAuthorSchemaType(entity?.type) === 'Person'\n ? { image }\n : { logo: image }\n}\n\n/** An entry, as far as author resolution is concerned. */\nexport interface AuthorBearingEntry {\n /** Reference into `hosts/{hostId}/authors` (AGL-2486). */\n authorId?: string\n /** The legacy free-typed byline (AGL-686). Still read, forever. */\n authorName?: string\n}\n\n/**\n * The author record an entry publishes under, or `null` for \"the site\".\n *\n * ## Precedence, and why the legacy string keeps working\n *\n * 1. `entry.authorId` resolving against the host's authors — the new shape.\n * 2. `entry.authorName` — every entry written before AGL-2486, and every\n * entry whose editor chose \"custom byline\" instead of a record. Promoted\n * to a `Person` with just a name, which is EXACTLY the JSON-LD the tenant\n * already emitted for it, so no published page's structured data changes.\n * 3. `null` — the caller falls back to the site's publisher entity, as before.\n *\n * An `authorId` pointing at a DELETED author falls through to step 2 and then\n * to the site rather than resolving to nothing: an author record removed from\n * the masthead must not silently strip the byline off ten years of posts.\n */\nexport function resolveEntryAuthor(\n entry: AuthorBearingEntry | null | undefined,\n authors?: readonly ContentAuthorRecord[] | null,\n): ContentAuthorRecord | null {\n const authorId = (entry?.authorId ?? '').trim()\n if (authorId) {\n const match = (authors ?? []).find((author) => author?.$id === authorId)\n const normalized = normalizeContentAuthor(match, authorId)\n if (normalized) return normalized\n }\n const legacy = (entry?.authorName ?? '').trim()\n if (legacy) {\n return { type: HostEntityType.PERSON, name: legacy, sameAs: [] }\n }\n return null\n}\n\n/**\n * The byline TEXT for an entry — what `{{entry.author}}` and the Entry Meta\n * block print (AGL-1459). Resolves a record's name, falls back to the legacy\n * string, and returns `''` when neither exists so the block renders nothing\n * rather than the word \"undefined\".\n */\nexport function resolveEntryAuthorName(\n entry: AuthorBearingEntry | null | undefined,\n authors?: readonly ContentAuthorRecord[] | null,\n): string {\n return resolveEntryAuthor(entry, authors)?.name ?? ''\n}\n\n\n/** The site fields {@link siteEntityJsonLd} falls back to. */\nexport interface SiteEntityHost {\n $id?: string\n displayName?: string\n seo?: {\n title?: string\n description?: string\n entity?: HostSeoEntity | null\n } | null\n /**\n * The contact card behind the AGL-1022 host tokens. Its `socialLinks` are\n * the site's own profiles, which is what {@link siteProfileUrls} reads.\n */\n business?: {\n socialLinks?: Array<{ label?: string; url?: string } | null> | null\n } | null\n}\n\n/** The fragment identifier the site entity is published under. */\nexport const SITE_ENTITY_FRAGMENT = '#organization'\n\n/**\n * EVERY PROFILE THE SITE CLAIMS AS ITSELF — `schema.org` `sameAs` (AGL-3148).\n *\n * Two fields hold these and only one was ever published. `seo.entity.sameAs`\n * is the SEO form, and a production read found it unset on every host on the\n * platform; `business.socialLinks` is the contact card the site's own footer\n * renders from, and it is the one publishers actually fill in. So the\n * structured data named no profiles at all on any site, while the profiles sat\n * one field over being drawn into the page.\n *\n * Folding the second into `sameAs` is the AGL-2516 move one level up: a link\n * the SITE prints is a profile the site claims, and asking a publisher to\n * retype it into an SEO field to be believed is asking them to maintain the\n * same list twice. The entity field stays FIRST, because it is the one written\n * deliberately for this purpose.\n *\n * https-only and de-duplicated, matching both serializers beside it: a profile\n * URL that is not fetchable teaches a consumer nothing, and one identity listed\n * twice reads as two.\n */\nexport function siteProfileUrls(\n host: SiteEntityHost | null | undefined,\n): string[] {\n const rendered = (host?.business?.socialLinks ?? []).map(\n (link) => link?.url ?? '',\n )\n return Array.from(\n new Set(\n [...(host?.seo?.entity?.sameAs ?? []), ...rendered]\n .map((value) => text(value, 800))\n .filter((value) => /^https:\\/\\//i.test(value)),\n ),\n ).slice(0, AUTHOR_SAME_AS_MAX)\n}\n\n/**\n * The X (Twitter) handle among a set of profile URLs, as `@name` (AGL-3148).\n *\n * `twitter:site` and `twitter:creator` take a handle, and nothing on the\n * platform stores one — what publishers and authors store is the profile URL,\n * in the same list that feeds `sameAs`. Reading the handle back out of the URL\n * is what lets both cards be attributed without a second field to fill in and\n * keep in step.\n *\n * Both hostnames, because a link saved before the rename is still the same\n * profile. Only a bare profile path qualifies: `x.com/AglynSoftware` is an\n * account, while `x.com/AglynSoftware/status/123` is one post by it and\n * `x.com/i/lists/9` is not an account at all — attributing a card to either\n * would name something that is not the author of anything.\n */\nexport function xHandleFromProfiles(\n urls: readonly string[] | null | undefined,\n): string | undefined {\n for (const value of urls ?? []) {\n let url: URL\n try {\n url = new URL(value)\n } catch {\n continue\n }\n if (!/^(www\\.)?(x|twitter)\\.com$/i.test(url.hostname)) continue\n const segments = url.pathname.split('/').filter(Boolean)\n if (segments.length !== 1) continue\n const handle = segments[0]\n // X handles are 1–15 of `[A-Za-z0-9_]`. Anything else is one of the\n // reserved paths (`i`, `home`, `search`) rather than somebody's account.\n if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) continue\n if (/^(i|home|search|explore|notifications|messages|settings)$/i.test(handle))\n continue\n return `@${handle}`\n }\n return undefined\n}\n\n/**\n * The site's publisher as a STANDALONE, fully populated `schema.org` node\n * (AGL-2716).\n *\n * ## Why this exists next to {@link hostSeoEntityJsonLd}\n *\n * That function builds the value `publisher` takes: a `@type` and a `name`,\n * nested inside a `WebSite` or an `Article`, gated on the author having filled\n * in the Entity form. It is deliberately narrow, and every call site nests it.\n *\n * A consumer asking \"who runs this site, and how do I reach them\" — an AI\n * assistant answering a contact question, a readiness audit checking whether a\n * business is identifiable — looks for a TOP-LEVEL node of type `Organization`\n * carrying `name`, `description`, `contactPoint` and `address`. A publisher\n * nested two levels inside a `WebSite` answers a different question, and a\n * publisher that is absent because nobody filled in a form answers none.\n *\n * So this one:\n *\n * - is emitted as its own `<script type=\"application/ld+json\">`, at the top\n * level, where it can be found without walking into another node;\n * - FALLS BACK to the site's own identity when the Entity form is empty, so\n * every site on the platform publishes a complete entity rather than only\n * the sites whose author found the form;\n * - carries the contact and address fields, which are what turn \"a name\" into\n * \"a business a consumer can verify\".\n *\n * ## Why the fallback is safe\n *\n * It changes nothing that already exists: `hostSeoEntityJsonLd` still gates on\n * `entity.name`, so `publisher` appears exactly where it appeared before. The\n * fallback only decides what THIS node says, and a site's display name is its\n * publisher's name on the overwhelming majority of sites — the two differ when\n * a company's trading name differs from its site's, which is precisely the case\n * the Entity form exists to express.\n *\n * ## `@id`\n *\n * Both this node and the nested `publisher` carry the same `@id` when an\n * origin is known, so a consumer merges them into one entity instead of\n * reading a site that names its publisher twice with different detail.\n *\n * Returns `undefined` only when there is no name to be had from anywhere —\n * a host document with no entity, no display name and no SEO title.\n */\nexport function siteEntityJsonLd(\n host: SiteEntityHost | null | undefined,\n context?: ContentAuthorImageContext,\n): Record<string, unknown> | undefined {\n const entity = host?.seo?.entity ?? undefined\n const name =\n text(entity?.name, 400) ||\n text(host?.displayName, 400) ||\n text(host?.seo?.title, 400)\n if (!name) return undefined\n\n const origin = context?.origin?.replace(/\\/+$/, '') ?? ''\n const description =\n text(entity?.description, 800) || text(host?.seo?.description, 800)\n const url = text(entity?.url, 800) || origin\n\n /*\n `sameAs` is de-duplicated and https-only, matching the author serializer:\n a profile URL that is not fetchable teaches a consumer nothing, and the\n same profile listed twice reads as two identities.\n\n Through `siteProfileUrls` (AGL-3148) so the site's rendered social links\n count as profiles it claims, rather than only the SEO field that no host\n on the platform had filled in.\n */\n const sameAs = siteProfileUrls(host)\n\n /*\n A contactPoint needs something to contact. `contactType` alone is a label\n on nothing, so the point is emitted only when an address or a number is\n present — `schema.org` requires neither, and a consumer that finds a\n ContactPoint with no channel has been told less than it would learn from\n its absence.\n */\n const email = text(entity?.email, 320)\n const telephone = text(entity?.telephone, 64)\n const contactType = text(entity?.contactType, 120) || 'customer support'\n const contactPoint =\n email || telephone\n ? {\n '@type': 'ContactPoint',\n contactType,\n ...(email ? { email } : {}),\n ...(telephone ? { telephone } : {}),\n }\n : undefined\n\n const addressFields = {\n streetAddress: text(entity?.address?.streetAddress, 200),\n addressLocality: text(entity?.address?.addressLocality, 120),\n addressRegion: text(entity?.address?.addressRegion, 120),\n postalCode: text(entity?.address?.postalCode, 40),\n addressCountry: text(entity?.address?.addressCountry, 120),\n }\n const address = Object.entries(addressFields).filter(([, value]) => value)\n const schemaType = contentAuthorSchemaType(entity?.type)\n\n return {\n '@context': 'https://schema.org',\n '@type': schemaType,\n ...(origin ? { '@id': `${origin}/${SITE_ENTITY_FRAGMENT}` } : {}),\n name,\n ...(description ? { description } : {}),\n ...(url ? { url } : {}),\n // `logo` for an Organization, `image` for a Person — the one field whose\n // KEY the branch changes. Shared with the nested publisher so the two\n // nodes cannot describe the same entity with different pictures.\n ...hostSeoEntityImageJsonLd(entity, context),\n ...(sameAs.length ? { sameAs } : {}),\n ...(contactPoint ? { contactPoint } : {}),\n ...(address.length\n ? { address: { '@type': 'PostalAddress', ...Object.fromEntries(address) } }\n : {}),\n }\n}\n"],"names":["HostEntityType","MEDIA_ALT_MAX_LENGTH","absoluteMediaSrc","urlSlugSegment","AUTHORS_MAX_PER_HOST","AUTHOR_NAME_MAX_LENGTH","AUTHOR_SLUG_MAX_LENGTH","AUTHOR_ROUTE_SEGMENT","AUTHOR_SAME_AS_MAX","AUTHOR_LINKS_MAX","AUTHOR_SOCIAL_PLATFORMS","id","label","icon","authorLinkPlatform","platform","trim","undefined","find","entry","authorLinkLabel","link","known","text","url","SAFE_AUTHOR_LINK_HREF","normalizeContentAuthorLinks","value","Array","isArray","links","row","raw","test","push","iconPath","length","contentAuthorSchemaType","type","Number","PERSON","max","slice","normalizeContentAuthor","name","sameAs","map","item","filter","Boolean","$id","ORGANIZATION","slug","image","jobTitle","worksFor","bio","seoDescription","seoImage","seoImageAlt","contentAuthorSlug","options","author","authorName","authorId","contentAuthorSlugCandidates","from","Set","contentAuthorMatchesSlug","wanted","includes","contentAuthorPageUrl","AUTHOR_PAGE_SEGMENT","contentAuthorPageAtUrl","base","page","isFinite","Math","floor","contentAuthorPaginationLinks","positive","parsed","totalPages","href","n","prevUrl","nextUrl","parseContentAuthorRoute","segments","parts","authorSlug","isInteger","contentAuthorJsonLd","context","normalized","schemaType","hostId","origin","logo","hostSeoEntityJsonLd","entity","hostSeoEntityImageJsonLd","stored","resolveEntryAuthor","authors","match","legacy","resolveEntryAuthorName","SITE_ENTITY_FRAGMENT","siteProfileUrls","host","rendered","business","socialLinks","seo","xHandleFromProfiles","urls","URL","hostname","pathname","split","handle","siteEntityJsonLd","displayName","title","replace","description","email","telephone","contactType","contactPoint","addressFields","streetAddress","address","addressLocality","addressRegion","postalCode","addressCountry","Object","entries","fromEntries"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/content-authors.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HostEntityType } from '../foundation/definitions/platform.types'\nimport { MEDIA_ALT_MAX_LENGTH } from './media-metadata'\nimport { absoluteMediaSrc } from './media-ref'\nimport { urlSlugSegment } from './url-slug'\n\n/**\n * Custom content authors (AGL-2486).\n *\n * ## An author is a RECORD, not an account\n *\n * Until now a post's byline was `entry.authorName` — one free-typed string\n * (AGL-686), retyped on every entry, with no url, no portrait, no social\n * profiles and no way to say the piece was written by the company rather than\n * a person. That is enough to print a name under a headline and not enough to\n * be `schema.org/author`, which wants an ENTITY: something with a stable\n * identity a crawler can join across articles and against the site's own\n * publisher entity.\n *\n * So the author becomes a document the customer authors and an entry\n * REFERENCES (`entry.authorId`). Publishing a piece under a pen name, a guest\n * contributor, or the company itself is then the ordinary case rather than a\n * retyped string, and renaming an author updates every post at render time —\n * the same property `categoryId` bought the taxonomy in AGL-582.\n *\n * ## Scope: the HOST, at `hosts/{hostId}/authors/{authorId}`\n *\n * Not the org, and this follows the data rather than inventing a rule.\n * Content collections live at `hosts/{hostId}/collections/{collectionId}` and\n * their entries underneath; the console surface that manages them is\n * `/[orgSlug]/hosts/[host]/content`; and the entity an author has to\n * serialise consistently with — `host.seo.entity`, the site's publisher — is\n * a field of the host document. An author referenced from an entry therefore\n * resolves inside the same document tree the entry already lives in, with the\n * host's existing rules and the tenant's existing Admin-SDK reads. An\n * org-scoped store would need a second root, a second read on every entry\n * render, and a sharing model (\"which sites may publish under this byline\")\n * that nothing in the product asks for yet.\n *\n * ## The type is a BRANCH, not a label\n *\n * `schema.org/Person` and `schema.org/Organization` do not carry the same\n * fields: a Person has `jobTitle` and `worksFor` and its portrait is `image`;\n * an Organization has no job title and its mark is `logo`. Emitting one\n * shape with the other's keys is invalid structured data that validates as\n * \"ignored\", so {@link contentAuthorJsonLd} branches on the type and the\n * console hides the fields that do not apply.\n */\n\n/**\n * How many authors one host may hold (AGL-2486).\n *\n * A flat PLATFORM cap, deliberately, in the shape of\n * `ENTRIES_MAX_PER_COLLECTION` / `ACTIONS_MAX_PER_HOST` (AGL-2266) and\n * `WEBHOOK_MAX_PER_HOST` (AGL-1360): nothing here is priced, no\n * `OrgEntitlements` key is added, every plan gets the same number. The reason\n * a number exists at all is the one those issues wrote down — a\n * client-creatable host subcollection with no cap is unbounded Firestore\n * documents mintable from the browser against a $0 subscription — and adding\n * a new subcollection without one would be that hole, freshly dug.\n *\n * 200 is far past any real masthead (the largest content collection in\n * production holds double digits of ENTRIES, let alone bylines) and small\n * enough that the console can list every author in a single unpaginated read.\n */\nexport const AUTHORS_MAX_PER_HOST = 200\n\n/** Longest stored author name; the byline is a name, not a paragraph. */\nexport const AUTHOR_NAME_MAX_LENGTH = 120\n\n/** Longest stored author slug; it is one path segment, not a sentence. */\nexport const AUTHOR_SLUG_MAX_LENGTH = 80\n\n/**\n * The site-wide segment an author's page lives under (AGL-2518):\n * `/author/{slug}`, and `/author/{slug}/page/{n}` after it.\n *\n * Singular, and site-wide rather than nested under a collection. AGL-2517\n * put this archive at `/{collection}/author/{slug}`, which gives one person\n * as many pages as the site has collections — three partial archives of the\n * same author, none of them the address a byline should link to, all three\n * competing for the same search result. A person is not a property of a\n * collection.\n *\n * NOT added to `RESERVED_SCREEN_ROUTE_SEGMENTS`. The catch-all resolves\n * published screens BEFORE it reaches this route, exactly as it does for a\n * collection's own `/{slug}`, so a site that already publishes a page at\n * `/author` keeps it and simply has no author pages — the same trade every\n * other platform-built address on a tenant site makes.\n */\nexport const AUTHOR_ROUTE_SEGMENT = 'author'\n\n/** Most `sameAs` profile links one author may carry. */\nexport const AUTHOR_SAME_AS_MAX = 12\n\n/** Most display links one author may carry. */\nexport const AUTHOR_LINKS_MAX = 12\n\n/**\n * A link the author profile PRINTS, as opposed to one it only declares\n * (AGL-2516).\n *\n * `sameAs` already held profile URLs, and it is exactly the wrong shape to\n * render: a bare list of strings with no label and no icon, which a card can\n * only draw as a row of identical anonymous links. It exists for crawlers,\n * where a URL alone is the whole point.\n *\n * A link somebody CLICKS needs to say what it is before they click it, and\n * the two ways of saying that pull in opposite directions:\n *\n * * a KNOWN platform is recognised by its mark, and the mark is not the\n * author's to choose — an X link drawn with a GitHub glyph is a broken\n * link that still resolves, and letting anyone pick that is a footgun with\n * no upside. So {@link platform} fixes the icon AND the accessible name;\n * * anything else — a personal newsletter, a conference talk, an ORCID\n * record — has no mark anyone would recognise, so it takes a label and an\n * icon the author chooses.\n *\n * One field decides which of the two a row is, and the console shows the\n * fields that apply to it. See {@link AUTHOR_SOCIAL_PLATFORMS}.\n */\nexport interface ContentAuthorLink {\n /**\n * A known platform id from {@link AUTHOR_SOCIAL_PLATFORMS}, whose icon and\n * accessible name are FIXED. Empty (or unrecognised) makes this a custom\n * link, which is where {@link label} and {@link icon} apply instead.\n */\n platform?: string\n /** Custom links only — what this link is called. */\n label?: string\n /** Custom links only — the picked `mdi` icon id. */\n icon?: string\n /**\n * Custom links only — the picked icon's PATH, resolved where the catalog\n * is already loaded.\n *\n * The same split AGL-1212 made for besigner icons, for the same reason: the\n * mdi catalog is ~2.9 MB and only picker surfaces load it, so a renderer\n * given an id alone can only draw a fallback glyph. The id stays the source\n * of truth; the path travels with the document so the tenant never pays for\n * the catalog to draw one icon.\n */\n iconPath?: string\n /** Where it goes. `https:` or `mailto:` only — see the normalizer. */\n url?: string\n}\n\n/**\n * The platforms whose mark is fixed (AGL-2516).\n *\n * `icon` is an `mdi` id, resolved to a path by whichever surface draws it —\n * the renderer imports these few directly rather than through the catalog,\n * because the set is closed and known at build time.\n *\n * Ordered as a masthead would list them rather than alphabetically: the\n * places writers are actually followed first, then the ways to reach them.\n */\nexport const AUTHOR_SOCIAL_PLATFORMS: ReadonlyArray<{\n id: string\n label: string\n icon: string\n}> = [\n { id: 'x', label: 'X', icon: 'twitter' },\n { id: 'linkedin', label: 'LinkedIn', icon: 'linkedin' },\n { id: 'github', label: 'GitHub', icon: 'github' },\n { id: 'mastodon', label: 'Mastodon', icon: 'mastodon' },\n { id: 'youtube', label: 'YouTube', icon: 'youtube' },\n { id: 'instagram', label: 'Instagram', icon: 'instagram' },\n { id: 'facebook', label: 'Facebook', icon: 'facebook' },\n { id: 'website', label: 'Website', icon: 'web' },\n { id: 'email', label: 'Email', icon: 'email' },\n { id: 'rss', label: 'RSS', icon: 'rss' },\n]\n\n/** The platform a link declares, or `undefined` when it is a custom one. */\nexport function authorLinkPlatform(\n platform: string | undefined | null,\n): (typeof AUTHOR_SOCIAL_PLATFORMS)[number] | undefined {\n const id = typeof platform === 'string' ? platform.trim() : ''\n if (!id) return undefined\n return AUTHOR_SOCIAL_PLATFORMS.find((entry) => entry.id === id)\n}\n\n/**\n * What a link is CALLED, which is never the author's choice on a known\n * platform.\n *\n * A custom link with no label falls back to its own URL rather than to\n * something generic: \"Link\" beside four other \"Link\"s is not an accessible\n * name, and the URL at least distinguishes them.\n */\nexport function authorLinkLabel(link: ContentAuthorLink): string {\n const known = authorLinkPlatform(link.platform)\n if (known) return known.label\n return text(link.label, 60) || text(link.url, 400)\n}\n\n/**\n * Schemes an author link may use.\n *\n * `https:` and `mailto:` only, matching the share bar and the Entry Author\n * card's own href guard. `http:` is excluded deliberately — a byline link is\n * published on every post its author wrote, and a mixed-content warning\n * across a whole archive is a poor trade for a scheme nobody should still be\n * publishing. Everything else, `javascript:` first among them, is a script\n * injection wearing a URL.\n */\nconst SAFE_AUTHOR_LINK_HREF = /^(https:\\/\\/|mailto:)/i\n\n/**\n * Sanitize the stored link rows into the shape a renderer may assume.\n *\n * Drops any row without a usable, safely-schemed URL — a link that goes\n * nowhere is worse than an absent one, because it still takes a click. A row\n * naming a platform keeps ONLY the platform (its label and icon come from the\n * registry, so a stored label or icon beside one is stale data that must not\n * win); a custom row keeps its label and icon and drops any platform id that\n * did not resolve.\n */\nexport function normalizeContentAuthorLinks(\n value: unknown,\n): ContentAuthorLink[] {\n if (!Array.isArray(value)) return []\n const links: ContentAuthorLink[] = []\n for (const row of value as unknown[]) {\n if (!row || typeof row !== 'object') continue\n const raw = row as Record<string, unknown>\n const url = text(raw['url'], 400)\n if (!url || !SAFE_AUTHOR_LINK_HREF.test(url)) continue\n const known = authorLinkPlatform(raw['platform'] as never)\n if (known) {\n links.push({ platform: known.id, url })\n } else {\n links.push({\n label: text(raw['label'], 60),\n icon: text(raw['icon'], 120),\n iconPath: text(raw['iconPath'], 4000),\n url,\n })\n }\n if (links.length >= AUTHOR_LINKS_MAX) break\n }\n return links\n}\n\n/**\n * One custom author, as stored at `hosts/{hostId}/authors/{authorId}`.\n *\n * Every field is optional at the type level because `strictNullChecks` is off\n * repo-wide and because a document written by an older console must keep\n * reading — {@link normalizeContentAuthor} is what turns a stored shape into\n * something safe to serialise, and it is the only thing that should.\n */\nexport interface ContentAuthorRecord {\n $id?: string\n /**\n * `Person` or `Organization` — the SAME enum the site's SEO entity uses\n * (`host.seo.entity.type`), so the two serialise through one branch rather\n * than two spellings of the same question.\n *\n * Stored as the numeric enum by this feature's editor. Read through\n * {@link contentAuthorSchemaType}, which also accepts the STRING form,\n * because the Setup → SEO → Entity form has always written `\"1\"` / `\"2\"`\n * (its Select options are template literals) — see that helper.\n */\n type?: HostEntityType | string | number\n /** The byline. The only field an author cannot be published without. */\n name?: string\n /**\n * The segment that addresses this author's page — `/author/{slug}`\n * (AGL-2518).\n *\n * Optional, and {@link contentAuthorSlug} falls back to the slugified name,\n * so an author who never opens the field still has a working, readable\n * address. It exists for the two cases a derived slug cannot serve:\n *\n * * a RENAME. The name is the byline and it changes — a married name, a\n * pen name, a company rebrand — and a derived slug moves the page with\n * it, breaking every link anyone ever shared. A stored slug is the\n * author saying \"this address is mine regardless of what you call me\";\n * * a COLLISION. Two people named Chris Taylor derive one segment, and the\n * archive would then hold both their work under one page with no way to\n * separate it.\n *\n * Matching still accepts the record id and the slugified display name (see\n * `entryMatchesAuthorRoute`), so setting this never breaks an address that\n * already worked — it only adds one.\n */\n slug?: string\n /** Author page / personal site — `schema.org` `url`. */\n url?: string\n /**\n * Portrait or logo. A `media:` reference (what the console's media picker\n * writes) or a plain URL — an external avatar is a legitimate answer, so\n * the field accepts both and {@link contentAuthorJsonLd} resolves whichever\n * arrives through the same helper `og:image` uses.\n *\n * Emitted as `image` for a Person and `logo` for an Organization.\n */\n image?: string\n /** Person only — `schema.org/Person.jobTitle`. */\n jobTitle?: string\n /** Person only — the organization they write for (`worksFor.name`). */\n worksFor?: string\n /**\n * Profile URLs (social, ORCID, Crunchbase…) — `schema.org` `sameAs`.\n *\n * Declared, never drawn. {@link links} is the rendered list; every link\n * there is folded into the emitted `sameAs` too, so an author who fills in\n * the visible row does not also have to retype the URL for the crawler.\n */\n sameAs?: string[]\n /** The links the author profile PRINTS (AGL-2516). */\n links?: ContentAuthorLink[]\n /**\n * Byline blurb for the page. NOT structured data: `description` on a Person\n * is a claim about the person, and a marketing sentence is not that.\n */\n bio?: string\n /**\n * The `<meta name=\"description\">` for this author's page, overriding\n * {@link bio}.\n *\n * The two are different sentences written for different readers, which is\n * why one field cannot serve both. A bio is printed BESIDE the byline, on a\n * page whose heading already says whose it is, and it runs as long as the\n * person wants; a meta description is read alone in a search result, has to\n * name the person it is about, and is cut off past roughly 160 characters.\n * Falling back to the bio is still right — a long true sentence beats no\n * description at all — but an author who wants the snippet to read a\n * particular way now has somewhere to say so.\n */\n seoDescription?: string\n /**\n * The share card for this author's page, overriding {@link image}.\n *\n * `image` is a PORTRAIT: square, cropped to a face, and correct as the\n * `schema.org` image of a person. A share card is 1200×630 and gets\n * letterboxed or center-cropped when a square goes out in its place, which\n * is why the author page ships the small `summary` card while a page with\n * one of these ships the large one.\n *\n * A `media:` reference or a plain URL, resolved through the same\n * `resolveSocialImage` every other surface's card goes through.\n */\n seoImage?: string\n /**\n * `og:image:alt` for {@link seoImage} — what the card SHOWS, for the screen\n * readers that announce a social preview.\n *\n * Stored beside the reference rather than derived, and read only when\n * `seoImage` is the image that won: a description carried over to the\n * portrait would describe a picture the card does not show.\n */\n seoImageAlt?: string\n}\n\n/**\n * The author's schema type, tolerant of every spelling the store holds.\n *\n * The numeric enum is what `HostEntityType` declares, but the Setup → SEO →\n * Entity form persists its Select value — a TEMPLATE LITERAL, i.e. the string\n * `\"1\"` or `\"2\"` — and the tenant's structured data compared that with\n * `=== HostEntityType.PERSON` (the number `2`). Strict equality across a\n * string and a number is always false, so a site that declared itself a\n * Person published `\"@type\": \"Organization\"` regardless (AGL-2486). Coercing\n * here, once, is what stops the same defect being re-derived per call site.\n *\n * Anything unrecognised — absent, empty, a stray value — resolves to\n * `Organization`, which is the default this repo has always emitted.\n */\nexport function contentAuthorSchemaType(\n type: HostEntityType | string | number | undefined | null,\n): 'Person' | 'Organization' {\n return Number(type) === HostEntityType.PERSON ? 'Person' : 'Organization'\n}\n\nconst text = (value: unknown, max = AUTHOR_NAME_MAX_LENGTH): string =>\n typeof value === 'string' ? value.trim().slice(0, max) : ''\n\n/**\n * A stored author document, sanitized into the shape the rest of the codebase\n * may assume — the {@link mapCollectionCategories} treatment, one collection\n * over: only strings survive, `sameAs` keeps string entries only, and an\n * author with no name resolves to `null` because a nameless byline is not a\n * byline and `schema.org` requires `name` on both branches.\n */\nexport function normalizeContentAuthor(\n value: unknown,\n id?: string,\n): ContentAuthorRecord | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const name = text(raw['name'])\n if (!name) return null\n const sameAs = Array.isArray(raw['sameAs'])\n ? (raw['sameAs'] as unknown[])\n .map((item) => text(item, 400))\n .filter(Boolean)\n .slice(0, AUTHOR_SAME_AS_MAX)\n : []\n return {\n ...(id ? { $id: id } : typeof raw['$id'] === 'string' ? { $id: raw['$id'] } : {}),\n type: contentAuthorSchemaType(raw['type'] as never) === 'Person'\n ? HostEntityType.PERSON\n : HostEntityType.ORGANIZATION,\n name,\n // Through the URL slugifier rather than stored raw: this is a path\n // segment, and a stored `Chris Taylor` would build `/author/Chris Taylor`\n // while every incoming request arrived slugified and matched nothing.\n slug: urlSlugSegment(text(raw['slug'], AUTHOR_SLUG_MAX_LENGTH)),\n url: text(raw['url'], 400),\n image: text(raw['image'], 1000),\n jobTitle: text(raw['jobTitle']),\n worksFor: text(raw['worksFor']),\n sameAs,\n links: normalizeContentAuthorLinks(raw['links']),\n bio: text(raw['bio'], 600),\n // Shorter than the bio it overrides, because it is read alone in a search\n // result rather than beside a heading — past this nothing survives the\n // snippet, so storing more only hides the cut from whoever wrote it.\n seoDescription: text(raw['seoDescription'], 300),\n seoImage: text(raw['seoImage'], 1000),\n seoImageAlt: text(raw['seoImageAlt'], MEDIA_ALT_MAX_LENGTH),\n }\n}\n\n/* ── The author's page (AGL-2518) ───────────────────────────────────────── */\n\n/** How a caller names the author whose page it wants addressed. */\nexport interface ContentAuthorAddress {\n /** The resolved record, when there is one. */\n author?: ContentAuthorRecord | null\n /** `entry.authorId` — a reference that may not have resolved. */\n authorId?: string\n /** The legacy free-typed byline (AGL-686). */\n authorName?: string\n}\n\n/**\n * The segment that addresses one author's page.\n *\n * Precedence is the author's stored {@link ContentAuthorRecord.slug} first\n * and the slugified name second — the opposite of what AGL-2517 built, which\n * put the record ID first \"so the link survives a rename\".\n *\n * A Firestore auto-id does survive a rename, and it is also unreadable. This\n * is a PUBLIC address on a marketing site: it goes in a byline, a share\n * sheet, a CV. `/author/hT3kQ9xLmZ2` is not an address anyone can read, and\n * the rename it protects against is answered better by the stored slug, which\n * is stable AND readable. The id is still ACCEPTED on the way in (see\n * `entryMatchesAuthorRoute`), so a link built under the old precedence keeps\n * resolving.\n *\n * Empty when there is nothing addressable, so a caller renders plain text\n * rather than a link to `/author/`.\n */\nexport function contentAuthorSlug(options: ContentAuthorAddress): string {\n return (\n urlSlugSegment(options.author?.slug) ||\n urlSlugSegment(options.author?.name) ||\n urlSlugSegment(options.authorName) ||\n urlSlugSegment(options.authorId) ||\n urlSlugSegment(options.author?.$id)\n )\n}\n\n/**\n * Every segment that should resolve to this author.\n *\n * The archive answers to the stored slug, the display name, the legacy\n * free-typed byline and the record id, because which of those a link happens\n * to carry depends on when it was built and by what — and a byline that\n * 404s is worse than one pointing at a page with an unexpected URL.\n *\n * Deduped, and empties dropped: a set containing `''` would match a request\n * for `/author/` against every author on the site.\n */\nexport function contentAuthorSlugCandidates(\n options: ContentAuthorAddress,\n): string[] {\n return Array.from(\n new Set(\n [\n options.author?.slug,\n options.author?.name,\n options.authorName,\n options.authorId,\n options.author?.$id,\n ]\n .map((value) => urlSlugSegment(value))\n .filter(Boolean),\n ),\n )\n}\n\n/** Does `slug` address this author? */\nexport function contentAuthorMatchesSlug(\n options: ContentAuthorAddress,\n slug: string | undefined | null,\n): boolean {\n const wanted = urlSlugSegment(slug)\n if (!wanted) return false\n return contentAuthorSlugCandidates(options).includes(wanted)\n}\n\n/**\n * The author's page on this site — `/author/{slug}` (AGL-2518).\n *\n * Empty when nothing addresses the author, which is what lets a template bind\n * this unconditionally: a link whose href does not resolve renders as inert\n * markup of the same element (AGL-1268/1357), so an entry with no byline\n * shows no link rather than one pointing nowhere.\n */\nexport function contentAuthorPageUrl(options: ContentAuthorAddress): string {\n const slug = contentAuthorSlug(options)\n return slug ? `/${AUTHOR_ROUTE_SEGMENT}/${slug}` : ''\n}\n\n/**\n * The paginated sub-path of an author page — `/author/{slug}/page/{n}`.\n *\n * The same word the collection listing pages with, deliberately: a reader who\n * has learned one URL shape on the site has learned both, and a second\n * spelling would buy nothing. Declared here rather than imported from\n * `collection-entries`, which imports THIS module.\n */\nconst AUTHOR_PAGE_SEGMENT = 'page'\n\n/**\n * The canonical URL of one page of an author's archive (AGL-2518).\n *\n * Page 1 is the bare `/author/{slug}` — there is no `/author/{slug}/page/1`,\n * so the first page cannot become a second address for itself. The rule\n * `collectionListUrl` states, one route over.\n */\nexport function contentAuthorPageAtUrl(\n options: ContentAuthorAddress & { page?: number | null },\n): string {\n const base = contentAuthorPageUrl(options)\n if (!base) return ''\n const page = Number(options.page)\n return Number.isFinite(page) && page > 1\n ? `${base}/${AUTHOR_PAGE_SEGMENT}/${Math.floor(page)}`\n : base\n}\n\n/**\n * Where an author archive's pager can go from here (AGL-2518).\n *\n * The `collectionPaginationLinks` contract, restated for this route: **the\n * edges resolve to the empty string, never to a URL**, which is what lets a\n * designed author template bind `{{pagination.prevUrl}}` unconditionally. A\n * link whose href is `''` renders as an inert placeholder of the same element\n * (AGL-1268/1357), which is the correct pager on page 1 of 1.\n *\n * ONE computation, because it has the same two consumers that must agree: the\n * built-in fallback's pager nodes and the tokens a template binds.\n */\nexport function contentAuthorPaginationLinks(\n options: ContentAuthorAddress & {\n page?: number | null\n totalPages?: number | null\n },\n): { page: number; totalPages: number; prevUrl: string; nextUrl: string } {\n const positive = (value: number | null | undefined): number => {\n const parsed = Math.floor(Number(value))\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 1\n }\n const page = positive(options.page)\n const totalPages = positive(options.totalPages)\n const href = (n: number) => contentAuthorPageAtUrl({ ...options, page: n })\n return {\n page,\n totalPages,\n prevUrl: page > 1 ? href(page - 1) : '',\n nextUrl: page < totalPages ? href(page + 1) : '',\n }\n}\n\n/** A parsed `/author/{slug}` route (AGL-2518). */\nexport interface ContentAuthorRoute {\n /** The addressed segment, normalized. */\n authorSlug: string\n /** 1-based list page; 1 on the bare archive. */\n page: number\n}\n\n/**\n * Parse a path into an author route, or `null` when the path is not one.\n *\n * The two shapes are `/author/{slug}` and `/author/{slug}/page/{n}`, matching\n * the collection listing's own pagination so a reader who has learned one URL\n * shape on the site has learned both.\n *\n * A pure parser, deliberately, for the reason `parseCollectionRoute` is one:\n * the tenant loader and its tests must not be able to disagree about what the\n * route table says. Page `0`, a negative page and a non-numeric page all\n * resolve to `null` rather than to page 1, so a nonsense URL 404s instead of\n * silently serving the first page under a second address.\n */\nexport function parseContentAuthorRoute(\n segments: readonly string[],\n): ContentAuthorRoute | null {\n const parts = (segments ?? []).filter(Boolean)\n if (parts[0] !== AUTHOR_ROUTE_SEGMENT) return null\n const authorSlug = urlSlugSegment(parts[1])\n if (!authorSlug) return null\n if (parts.length === 2) return { authorSlug, page: 1 }\n if (parts.length === 4 && parts[2] === AUTHOR_PAGE_SEGMENT) {\n const page = Number(parts[3])\n return Number.isInteger(page) && page >= 1 ? { authorSlug, page } : null\n }\n return null\n}\n\n/** How an author's `image` is turned into something a crawler can fetch. */\nexport interface ContentAuthorImageContext {\n /** The site's public origin, for absolutizing a site-relative CDN path. */\n origin?: string | null\n /** Host id, so a restricted org asset resolves for the site rendering it. */\n hostId?: string\n}\n\n/**\n * `schema.org` JSON-LD for one author (AGL-2486) — a `Person` or an\n * `Organization`, with the fields that branch actually defines.\n *\n * | field | Person | Organization |\n * | ---------- | ------------------------- | ------------ |\n * | `name` | required | required |\n * | `url` | optional | optional |\n * | portrait | `image` | `logo` |\n * | `jobTitle` | optional | — not valid |\n * | `worksFor` | optional (`Organization`) | — not valid |\n * | `sameAs` | optional | optional |\n *\n * The image goes through {@link absoluteMediaSrc}, the same resolver\n * `og:image` and `Article.image` use (AGL-1343/1407), so a portrait picked\n * from the DAM reaches the structured data as a fetchable absolute URL rather\n * than the literal string `media:{scope}/{id}`. An unresolvable reference —\n * or a site-relative path with no origin to resolve against — emits NO image\n * key at all rather than a broken one.\n *\n * Returns `undefined` for an author with no usable name, so every call site\n * can spread the result conditionally instead of emitting `\"author\": null`.\n *\n * ## Escaping\n *\n * Nothing is escaped HERE, and that is correct: this returns a VALUE, and the\n * one place a value becomes markup is `Aglyn.safeJsonLd`, which every\n * `<script type=\"application/ld+json\">` in the repo is built with. It escapes\n * `<`, `>`, `&` and U+2028/U+2029 to their `\\uXXXX` forms after\n * `JSON.stringify`, so an author named `</script><img onerror=…>` serialises\n * as inert text rather than closing the element (AGL-496). Escaping a second\n * time here would double-encode and put literal backslashes in a byline.\n */\nexport function contentAuthorJsonLd(\n author: ContentAuthorRecord | null | undefined,\n context?: ContentAuthorImageContext,\n): Record<string, unknown> | undefined {\n const normalized = normalizeContentAuthor(author, author?.$id)\n if (!normalized) return undefined\n const schemaType = contentAuthorSchemaType(normalized.type)\n const image = absoluteMediaSrc(normalized.image, {\n hostId: context?.hostId,\n origin: context?.origin,\n })\n const sameAs = Array.from(\n new Set([\n ...(normalized.sameAs ?? []),\n ...(normalized.links ?? [])\n .map((link) => link.url ?? '')\n .filter((url) => /^https:\\/\\//i.test(url)),\n ]),\n ).slice(0, AUTHOR_SAME_AS_MAX)\n return {\n '@type': schemaType,\n name: normalized.name,\n ...(normalized.url ? { url: normalized.url } : {}),\n // `image` on a Person, `logo` on an Organization — the one field whose\n // KEY the branch changes, which is why a shared \"entity\" serializer that\n // took a type parameter and emitted `logo` for both would be wrong.\n ...(image\n ? schemaType === 'Person'\n ? { image }\n : { logo: image }\n : {}),\n ...(schemaType === 'Person' && normalized.jobTitle\n ? { jobTitle: normalized.jobTitle }\n : {}),\n ...(schemaType === 'Person' && normalized.worksFor\n ? { worksFor: { '@type': 'Organization', name: normalized.worksFor } }\n : {}),\n // A link the profile PRINTS is also a profile the author claims, so the\n // rendered rows join `sameAs` rather than being a second list to keep in\n // step by hand (AGL-2516). `mailto:` is excluded: `sameAs` is for pages\n // that identify the same entity, and an address is not one.\n ...(sameAs.length ? { sameAs } : {}),\n }\n}\n\n/** The site's own publisher entity, as `host.seo.entity` stores it. */\nexport interface HostSeoEntity {\n type?: HostEntityType | string | number\n name?: string\n logo?: string\n /** What the publisher IS, in a sentence (AGL-2716). */\n description?: string\n /** The publisher's canonical address, when it is not this site. */\n url?: string\n /** Profiles that identify the same entity. */\n sameAs?: string[]\n /** Contact details, assembled into a `contactPoint` at serialization. */\n email?: string\n telephone?: string\n contactType?: string\n /** Postal address, emitted as a `PostalAddress`. */\n address?: {\n streetAddress?: string\n addressLocality?: string\n addressRegion?: string\n postalCode?: string\n addressCountry?: string\n }\n}\n\n/**\n * `schema.org` JSON-LD for the SITE entity (`host.seo.entity`) — the value\n * `Article.publisher` and `WebSite.publisher` are built from.\n *\n * Lives beside {@link contentAuthorJsonLd} on purpose (AGL-2486): a site\n * entity and a post author answer the same question about two different\n * subjects, and the reason the Person branch was broken for two years is that\n * the answer was written inline at its one call site where nothing could\n * test it. One module, two entry points, one `Person`/`Organization`\n * decision — {@link contentAuthorSchemaType}.\n *\n * `logo` is emitted verbatim on BOTH branches, unchanged from what the tenant\n * has always published. It is `host.seo.entity.logo`, a plain URL field on\n * the Setup form rather than a media-picker target, and re-keying it to\n * `image` for a Person would change the publisher block on every page of\n * every site that set one — a separate decision from this one.\n */\nexport function hostSeoEntityJsonLd(\n entity: HostSeoEntity | null | undefined,\n): Record<string, unknown> | undefined {\n const name = text(entity?.name, 400)\n if (!name) return undefined\n return {\n '@type': contentAuthorSchemaType(entity?.type),\n name,\n }\n}\n\n/**\n * The publisher's picture, under the property its TYPE actually has\n * (AGL-2486).\n *\n * `schema.org` gives `logo` to an Organization and not to a Person; a Person\n * carries `image`. The tenant emitted `logo` for both, so a site that declared\n * itself a Person published a property its own `@type` does not define —\n * ignored by every consumer, which is the worst kind of wrong: the field is\n * set, the console says it is published, and nothing renders it.\n *\n * That is the same shape as the bug one function up, where a string `\"2\"` was\n * compared with a numeric enum and no site could ever publish `Person` at all.\n * Both resolve here so the type is decided in exactly one place.\n *\n * Returns a spreadable object so callers stay a spread rather than a ternary:\n * `{...hostSeoEntityImageJsonLd(entity)}` adds nothing when there is no\n * picture, which is what an absent property should cost.\n */\nexport function hostSeoEntityImageJsonLd(\n entity: HostSeoEntity | null | undefined,\n context?: ContentAuthorImageContext,\n): Record<string, string> {\n /*\n Through `absoluteMediaSrc`, exactly as an author's portrait goes\n (AGL-1343/1407). The stored value has three generations — a raw storage\n URL, a site-relative CDN path, and a `media:` reference — and the site\n entity's was the one path still emitting whichever it found. A `media:`\n reference reaches Google as the literal string `media:{scope}/{id}`,\n which does not fetch; a site-relative path with no origin does not either.\n\n An unresolvable value emits NO key rather than a broken one — the same\n rule the author serializer states, and the reason both return a spreadable\n object instead of a URL.\n */\n /*\n TRIMMED first. `absoluteMediaSrc` does not trim, and a whitespace-only\n value — what a field cleared by typing spaces leaves behind — is not\n empty to it: it reads as a relative path and comes back joined to the\n origin, so the site would publish `https://example.com/ ` as its\n publisher logo. Trimmed here rather than in the resolver, which og:image\n and every author portrait also go through.\n */\n const stored = typeof entity?.logo === 'string' ? entity.logo.trim() : ''\n const image = stored\n ? absoluteMediaSrc(stored, {\n hostId: context?.hostId,\n origin: context?.origin,\n })\n : ''\n if (!image) return {}\n return contentAuthorSchemaType(entity?.type) === 'Person'\n ? { image }\n : { logo: image }\n}\n\n/** An entry, as far as author resolution is concerned. */\nexport interface AuthorBearingEntry {\n /** Reference into `hosts/{hostId}/authors` (AGL-2486). */\n authorId?: string\n /** The legacy free-typed byline (AGL-686). Still read, forever. */\n authorName?: string\n}\n\n/**\n * The author record an entry publishes under, or `null` for \"the site\".\n *\n * ## Precedence, and why the legacy string keeps working\n *\n * 1. `entry.authorId` resolving against the host's authors — the new shape.\n * 2. `entry.authorName` — every entry written before AGL-2486, and every\n * entry whose editor chose \"custom byline\" instead of a record. Promoted\n * to a `Person` with just a name, which is EXACTLY the JSON-LD the tenant\n * already emitted for it, so no published page's structured data changes.\n * 3. `null` — the caller falls back to the site's publisher entity, as before.\n *\n * An `authorId` pointing at a DELETED author falls through to step 2 and then\n * to the site rather than resolving to nothing: an author record removed from\n * the masthead must not silently strip the byline off ten years of posts.\n */\nexport function resolveEntryAuthor(\n entry: AuthorBearingEntry | null | undefined,\n authors?: readonly ContentAuthorRecord[] | null,\n): ContentAuthorRecord | null {\n const authorId = (entry?.authorId ?? '').trim()\n if (authorId) {\n const match = (authors ?? []).find((author) => author?.$id === authorId)\n const normalized = normalizeContentAuthor(match, authorId)\n if (normalized) return normalized\n }\n const legacy = (entry?.authorName ?? '').trim()\n if (legacy) {\n return { type: HostEntityType.PERSON, name: legacy, sameAs: [] }\n }\n return null\n}\n\n/**\n * The byline TEXT for an entry — what `{{entry.author}}` and the Entry Meta\n * block print (AGL-1459). Resolves a record's name, falls back to the legacy\n * string, and returns `''` when neither exists so the block renders nothing\n * rather than the word \"undefined\".\n */\nexport function resolveEntryAuthorName(\n entry: AuthorBearingEntry | null | undefined,\n authors?: readonly ContentAuthorRecord[] | null,\n): string {\n return resolveEntryAuthor(entry, authors)?.name ?? ''\n}\n\n/**\n * Does the entry name a byline of its OWN — an author record or a custom\n * byline — rather than falling back to the site?\n *\n * A byline is required to publish or schedule an entry. The site fallback in\n * {@link resolveEntryAuthor} stays for what is already stored, but a post that\n * goes live from here on must say who wrote it: an unattributed article reads\n * as unowned to a visitor, and Entry Meta prints no name at all for it.\n *\n * Asks what is STORED, not whether it resolves. An `authorId` whose record was\n * deleted still carries its denormalized `authorName` and still renders a\n * name, so refusing it would block unpublishing-and-republishing a post that\n * reads correctly today.\n */\nexport function entryHasByline(\n entry: AuthorBearingEntry | null | undefined,\n): boolean {\n return Boolean(\n (entry?.authorId ?? '').trim() || (entry?.authorName ?? '').trim(),\n )\n}\n\n/** The refusal a publish or schedule without a byline shows. */\nexport const ENTRY_BYLINE_REQUIRED_MESSAGE =\n 'Add an author before publishing — pick one or type a custom byline'\n\n\n/** The site fields {@link siteEntityJsonLd} falls back to. */\nexport interface SiteEntityHost {\n $id?: string\n displayName?: string\n seo?: {\n title?: string\n description?: string\n entity?: HostSeoEntity | null\n } | null\n /**\n * The contact card behind the AGL-1022 host tokens. Its `socialLinks` are\n * the site's own profiles, which is what {@link siteProfileUrls} reads.\n */\n business?: {\n socialLinks?: Array<{ label?: string; url?: string } | null> | null\n } | null\n}\n\n/** The fragment identifier the site entity is published under. */\nexport const SITE_ENTITY_FRAGMENT = '#organization'\n\n/**\n * EVERY PROFILE THE SITE CLAIMS AS ITSELF — `schema.org` `sameAs` (AGL-3148).\n *\n * Two fields hold these and only one was ever published. `seo.entity.sameAs`\n * is the SEO form, and a production read found it unset on every host on the\n * platform; `business.socialLinks` is the contact card the site's own footer\n * renders from, and it is the one publishers actually fill in. So the\n * structured data named no profiles at all on any site, while the profiles sat\n * one field over being drawn into the page.\n *\n * Folding the second into `sameAs` is the AGL-2516 move one level up: a link\n * the SITE prints is a profile the site claims, and asking a publisher to\n * retype it into an SEO field to be believed is asking them to maintain the\n * same list twice. The entity field stays FIRST, because it is the one written\n * deliberately for this purpose.\n *\n * https-only and de-duplicated, matching both serializers beside it: a profile\n * URL that is not fetchable teaches a consumer nothing, and one identity listed\n * twice reads as two.\n */\nexport function siteProfileUrls(\n host: SiteEntityHost | null | undefined,\n): string[] {\n const rendered = (host?.business?.socialLinks ?? []).map(\n (link) => link?.url ?? '',\n )\n return Array.from(\n new Set(\n [...(host?.seo?.entity?.sameAs ?? []), ...rendered]\n .map((value) => text(value, 800))\n .filter((value) => /^https:\\/\\//i.test(value)),\n ),\n ).slice(0, AUTHOR_SAME_AS_MAX)\n}\n\n/**\n * The X (Twitter) handle among a set of profile URLs, as `@name` (AGL-3148).\n *\n * `twitter:site` and `twitter:creator` take a handle, and nothing on the\n * platform stores one — what publishers and authors store is the profile URL,\n * in the same list that feeds `sameAs`. Reading the handle back out of the URL\n * is what lets both cards be attributed without a second field to fill in and\n * keep in step.\n *\n * Both hostnames, because a link saved before the rename is still the same\n * profile. Only a bare profile path qualifies: `x.com/AglynSoftware` is an\n * account, while `x.com/AglynSoftware/status/123` is one post by it and\n * `x.com/i/lists/9` is not an account at all — attributing a card to either\n * would name something that is not the author of anything.\n */\nexport function xHandleFromProfiles(\n urls: readonly string[] | null | undefined,\n): string | undefined {\n for (const value of urls ?? []) {\n let url: URL\n try {\n url = new URL(value)\n } catch {\n continue\n }\n if (!/^(www\\.)?(x|twitter)\\.com$/i.test(url.hostname)) continue\n const segments = url.pathname.split('/').filter(Boolean)\n if (segments.length !== 1) continue\n const handle = segments[0]\n // X handles are 1–15 of `[A-Za-z0-9_]`. Anything else is one of the\n // reserved paths (`i`, `home`, `search`) rather than somebody's account.\n if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) continue\n if (/^(i|home|search|explore|notifications|messages|settings)$/i.test(handle))\n continue\n return `@${handle}`\n }\n return undefined\n}\n\n/**\n * The site's publisher as a STANDALONE, fully populated `schema.org` node\n * (AGL-2716).\n *\n * ## Why this exists next to {@link hostSeoEntityJsonLd}\n *\n * That function builds the value `publisher` takes: a `@type` and a `name`,\n * nested inside a `WebSite` or an `Article`, gated on the author having filled\n * in the Entity form. It is deliberately narrow, and every call site nests it.\n *\n * A consumer asking \"who runs this site, and how do I reach them\" — an AI\n * assistant answering a contact question, a readiness audit checking whether a\n * business is identifiable — looks for a TOP-LEVEL node of type `Organization`\n * carrying `name`, `description`, `contactPoint` and `address`. A publisher\n * nested two levels inside a `WebSite` answers a different question, and a\n * publisher that is absent because nobody filled in a form answers none.\n *\n * So this one:\n *\n * - is emitted as its own `<script type=\"application/ld+json\">`, at the top\n * level, where it can be found without walking into another node;\n * - FALLS BACK to the site's own identity when the Entity form is empty, so\n * every site on the platform publishes a complete entity rather than only\n * the sites whose author found the form;\n * - carries the contact and address fields, which are what turn \"a name\" into\n * \"a business a consumer can verify\".\n *\n * ## Why the fallback is safe\n *\n * It changes nothing that already exists: `hostSeoEntityJsonLd` still gates on\n * `entity.name`, so `publisher` appears exactly where it appeared before. The\n * fallback only decides what THIS node says, and a site's display name is its\n * publisher's name on the overwhelming majority of sites — the two differ when\n * a company's trading name differs from its site's, which is precisely the case\n * the Entity form exists to express.\n *\n * ## `@id`\n *\n * Both this node and the nested `publisher` carry the same `@id` when an\n * origin is known, so a consumer merges them into one entity instead of\n * reading a site that names its publisher twice with different detail.\n *\n * Returns `undefined` only when there is no name to be had from anywhere —\n * a host document with no entity, no display name and no SEO title.\n */\nexport function siteEntityJsonLd(\n host: SiteEntityHost | null | undefined,\n context?: ContentAuthorImageContext,\n): Record<string, unknown> | undefined {\n const entity = host?.seo?.entity ?? undefined\n const name =\n text(entity?.name, 400) ||\n text(host?.displayName, 400) ||\n text(host?.seo?.title, 400)\n if (!name) return undefined\n\n const origin = context?.origin?.replace(/\\/+$/, '') ?? ''\n const description =\n text(entity?.description, 800) || text(host?.seo?.description, 800)\n const url = text(entity?.url, 800) || origin\n\n /*\n `sameAs` is de-duplicated and https-only, matching the author serializer:\n a profile URL that is not fetchable teaches a consumer nothing, and the\n same profile listed twice reads as two identities.\n\n Through `siteProfileUrls` (AGL-3148) so the site's rendered social links\n count as profiles it claims, rather than only the SEO field that no host\n on the platform had filled in.\n */\n const sameAs = siteProfileUrls(host)\n\n /*\n A contactPoint needs something to contact. `contactType` alone is a label\n on nothing, so the point is emitted only when an address or a number is\n present — `schema.org` requires neither, and a consumer that finds a\n ContactPoint with no channel has been told less than it would learn from\n its absence.\n */\n const email = text(entity?.email, 320)\n const telephone = text(entity?.telephone, 64)\n const contactType = text(entity?.contactType, 120) || 'customer support'\n const contactPoint =\n email || telephone\n ? {\n '@type': 'ContactPoint',\n contactType,\n ...(email ? { email } : {}),\n ...(telephone ? { telephone } : {}),\n }\n : undefined\n\n const addressFields = {\n streetAddress: text(entity?.address?.streetAddress, 200),\n addressLocality: text(entity?.address?.addressLocality, 120),\n addressRegion: text(entity?.address?.addressRegion, 120),\n postalCode: text(entity?.address?.postalCode, 40),\n addressCountry: text(entity?.address?.addressCountry, 120),\n }\n const address = Object.entries(addressFields).filter(([, value]) => value)\n const schemaType = contentAuthorSchemaType(entity?.type)\n\n return {\n '@context': 'https://schema.org',\n '@type': schemaType,\n ...(origin ? { '@id': `${origin}/${SITE_ENTITY_FRAGMENT}` } : {}),\n name,\n ...(description ? { description } : {}),\n ...(url ? { url } : {}),\n // `logo` for an Organization, `image` for a Person — the one field whose\n // KEY the branch changes. Shared with the nested publisher so the two\n // nodes cannot describe the same entity with different pictures.\n ...hostSeoEntityImageJsonLd(entity, context),\n ...(sameAs.length ? { sameAs } : {}),\n ...(contactPoint ? { contactPoint } : {}),\n ...(address.length\n ? { address: { '@type': 'PostalAddress', ...Object.fromEntries(address) } }\n : {}),\n }\n}\n"],"names":["HostEntityType","MEDIA_ALT_MAX_LENGTH","absoluteMediaSrc","urlSlugSegment","AUTHORS_MAX_PER_HOST","AUTHOR_NAME_MAX_LENGTH","AUTHOR_SLUG_MAX_LENGTH","AUTHOR_ROUTE_SEGMENT","AUTHOR_SAME_AS_MAX","AUTHOR_LINKS_MAX","AUTHOR_SOCIAL_PLATFORMS","id","label","icon","authorLinkPlatform","platform","trim","undefined","find","entry","authorLinkLabel","link","known","text","url","SAFE_AUTHOR_LINK_HREF","normalizeContentAuthorLinks","value","Array","isArray","links","row","raw","test","push","iconPath","length","contentAuthorSchemaType","type","Number","PERSON","max","slice","normalizeContentAuthor","name","sameAs","map","item","filter","Boolean","$id","ORGANIZATION","slug","image","jobTitle","worksFor","bio","seoDescription","seoImage","seoImageAlt","contentAuthorSlug","options","author","authorName","authorId","contentAuthorSlugCandidates","from","Set","contentAuthorMatchesSlug","wanted","includes","contentAuthorPageUrl","AUTHOR_PAGE_SEGMENT","contentAuthorPageAtUrl","base","page","isFinite","Math","floor","contentAuthorPaginationLinks","positive","parsed","totalPages","href","n","prevUrl","nextUrl","parseContentAuthorRoute","segments","parts","authorSlug","isInteger","contentAuthorJsonLd","context","normalized","schemaType","hostId","origin","logo","hostSeoEntityJsonLd","entity","hostSeoEntityImageJsonLd","stored","resolveEntryAuthor","authors","match","legacy","resolveEntryAuthorName","entryHasByline","ENTRY_BYLINE_REQUIRED_MESSAGE","SITE_ENTITY_FRAGMENT","siteProfileUrls","host","rendered","business","socialLinks","seo","xHandleFromProfiles","urls","URL","hostname","pathname","split","handle","siteEntityJsonLd","displayName","title","replace","description","email","telephone","contactType","contactPoint","addressFields","streetAddress","address","addressLocality","addressRegion","postalCode","addressCountry","Object","entries","fromEntries"],"mappings":"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"}
|
|
@@ -877,7 +877,7 @@ export const ASSIST_DOCS_INDEX = [
|
|
|
877
877
|
title: 'Edit from the live site',
|
|
878
878
|
heading: 'The bar',
|
|
879
879
|
anchor: '#the-bar',
|
|
880
|
-
text: 'From left to right:\n- the site\'s name, beside the Aglyn mark and the site\'s favicon, if it has one. It opens the site\'s dashboard in the console.\n- the screen serving the page. A blog post or another collection page shows the collection and the template screen that renders it, such as Blog entry · Post. A page no screen serves shows Unrouted page.\n- Draft changes, when the screen has a version newer than the one that\'s live.\n- Edit this page, which opens the besigner on that screen, at the version the page is serving.\n- today\'s page views across the site, and on Pro+ how many of them were of this page, as in 120 views today · 18 on this page. Days are counted in UTC. It opens the site\'s analytics.\n- Screens, plus Inbox and Orders where the inbox and commerce plugins are enabled on the site.\n- your email, which opens a menu with Account settings, Site dashboard and Disconnect.\n- ×.\nEvery link opens the console in a new tab, where your usual permissions apply. As you browse, the bar follows: go to another page and it names that page\'s screen.\nThe bar sits above the page rather than over it. The page moves down by the bar\'s height, and so does a site header pinned to the top of the window. On a phone, the bar keeps the site name,
|
|
880
|
+
text: 'From left to right:\n- the site\'s name, beside the Aglyn mark and the site\'s favicon, if it has one. It opens the site\'s dashboard in the console.\n- the screen serving the page. A blog post or another collection page shows the collection and the template screen that renders it, such as Blog entry · Post. A page no screen serves shows Unrouted page.\n- Draft changes, when the screen has a version newer than the one that\'s live.\n- Edit this page, which opens the besigner on that screen, at the version the page is serving. On a blog post or another collection entry it reads Edit template, because that screen is the design every entry shares, and Edit this entry beside it opens the entry itself — its text, cover and author — in the console\'s content editor.\n- today\'s page views across the site, and on Pro+ how many of them were of this page, as in 120 views today · 18 on this page. Days are counted in UTC. It opens the site\'s analytics.\n- Screens, plus Inbox and Orders where the inbox and commerce plugins are enabled on the site.\n- your email, which opens a menu with Account settings, Site dashboard and Disconnect.\n- ×.\nEvery link opens the console in a new tab, where your usual permissions apply. As you browse, the bar follows: go to another page and it names that page\'s screen.\nThe bar sits above the page rather than over it. The page moves down by the bar\'s height, and so does a site header pinned to the top of the window. On a phone, the bar keeps the site name, the edit links and ×, and a ⋯ menu holds the links, your email and Disconnect.'
|
|
881
881
|
},
|
|
882
882
|
{
|
|
883
883
|
path: '/building-sites/besigner/edit-from-the-live-site',
|
|
@@ -2753,7 +2753,7 @@ export const ASSIST_DOCS_INDEX = [
|
|
|
2753
2753
|
title: 'Build a blog',
|
|
2754
2754
|
heading: '2. Write entries',
|
|
2755
2755
|
anchor: '#2-write-entries',
|
|
2756
|
-
text: 'Add rich blog entries with images, a live preview, and scheduling so posts publish at the right time.\nNew entry, or clicking any row in the entries table, opens that entry on its own page. The page has its own address, so you can bookmark an entry you\'re mid-way through or paste the link to a colleague and they land on the same one. Back to entries — or your browser\'s Back button — returns to the list; if the entry has edits you haven\'t saved, you\'re asked before they\'re dropped.\nThe table opens with the most recently Published entries first. Click the Title, Status, Updated or Published header to sort by that column, and again to reverse it. Entries with nothing in the sorted column, such as a draft with no published date, follow the rest in either direction rather than dropping out. Filters in the table\'s toolbar narrows the list to one Status or one Category across the whole collection, not only the page you\'re on.\nEach entry carries, besides the title, excerpt, cover image, and markdown body:\n- Category — a single bucket (e.g. Guides) used for filtering and related posts, picked from the collection\'s category list (see below), never typed free-form.\n- Tags — comma-separated labels (e.g. nextjs, seo).\n- Author — the entry\'s byline: an author record from the Authors tab
|
|
2756
|
+
text: 'Add rich blog entries with images, a live preview, and scheduling so posts publish at the right time.\nNew entry, or clicking any row in the entries table, opens that entry on its own page. The page has its own address, so you can bookmark an entry you\'re mid-way through or paste the link to a colleague and they land on the same one. Back to entries — or your browser\'s Back button — returns to the list; if the entry has edits you haven\'t saved, you\'re asked before they\'re dropped.\nThe table opens with the most recently Published entries first. Click the Title, Status, Updated or Published header to sort by that column, and again to reverse it. Entries with nothing in the sorted column, such as a draft with no published date, follow the rest in either direction rather than dropping out. Filters in the table\'s toolbar narrows the list to one Status or one Category across the whole collection, not only the page you\'re on.\nEach entry carries, besides the title, excerpt, cover image, and markdown body:\n- Category — a single bucket (e.g. Guides) used for filtering and related posts, picked from the collection\'s category list (see below), never typed free-form.\n- Tags — comma-separated labels (e.g. nextjs, seo).\n- Author — the entry\'s byline: an author record from the Authors tab or a one-off custom byline. An entry needs one to be published or scheduled; a draft can be saved without it. See Authors.\n- SEO title / SEO description — search & social overrides; they fall back to the title and excerpt when blank.\n- Featured video — a film for the entry\'s page: one from your media library, a video link, or a Wistia link. It plays where the cover image would sit. See Build a video collection.\nScheduling\nThe same three publication controls live in two places, so you can decide when a '
|
|
2757
2757
|
},
|
|
2758
2758
|
{
|
|
2759
2759
|
path: '/building-sites/site-templates/build-a-blog',
|