@dogsbay/site-files 0.0.0-stage → 0.2.0-beta.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/canonical.d.ts +84 -0
- package/dist/canonical.js +82 -0
- package/dist/hreflang.d.ts +19 -0
- package/dist/hreflang.js +18 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/json-ld.d.ts +105 -0
- package/dist/json-ld.js +161 -0
- package/dist/llms.d.ts +72 -0
- package/dist/llms.js +156 -0
- package/dist/redirects.d.ts +68 -0
- package/dist/redirects.js +181 -0
- package/dist/robots.d.ts +25 -0
- package/dist/robots.js +20 -0
- package/dist/sitemap.d.ts +31 -0
- package/dist/sitemap.js +65 -0
- package/dist/text.d.ts +22 -0
- package/dist/text.js +45 -0
- package/dist/translations.d.ts +30 -0
- package/dist/translations.js +54 -0
- package/package.json +32 -3
- package/README.md +0 -3
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which URL a page of a versioned site names as its canonical, and whether
|
|
3
|
+
* it goes in the sitemap (dogsbay-ssg plans/canonical-versions.md, step 2).
|
|
4
|
+
* Both pipelines call this per page and turn the result into their own
|
|
5
|
+
* URLs: a page of an older version points at the same page in the default
|
|
6
|
+
* version, so search engines index the current docs.
|
|
7
|
+
*
|
|
8
|
+
* | Page | Canonical | Sitemap |
|
|
9
|
+
* |---|---|---|
|
|
10
|
+
* | in the default version | itself | yes |
|
|
11
|
+
* | older, and the default version has the page | that page | no |
|
|
12
|
+
* | older, and a redirect in the default version covers its path | the redirect's target | no |
|
|
13
|
+
* | older, only in its version | itself | yes |
|
|
14
|
+
* | in a `noindex: true` version | itself (robots noindex) | no |
|
|
15
|
+
* | in a `canonical: self` version | itself | yes |
|
|
16
|
+
*
|
|
17
|
+
* `sitemap: true | false` on a version overrides the last column (with
|
|
18
|
+
* `noindex`, true is refused at config load). A page's own front matter
|
|
19
|
+
* `canonicalUrl` (an absolute URL) wins over all of it; a redirect is
|
|
20
|
+
* followed only when it ends on a page of the default version or on
|
|
21
|
+
* another site.
|
|
22
|
+
*/
|
|
23
|
+
/** Per-version settings (`content.versions[]`). */
|
|
24
|
+
export interface VersionSeo {
|
|
25
|
+
/** `self`: every page is its own canonical. Default: the canonical-to-latest rule. */
|
|
26
|
+
canonical?: "self";
|
|
27
|
+
noindex?: boolean;
|
|
28
|
+
/** Override whether the version's pages are listed in the sitemap. */
|
|
29
|
+
sitemap?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface CanonicalQuery {
|
|
32
|
+
/** The page is in the default version (or the site has no version axis). */
|
|
33
|
+
isDefault: boolean;
|
|
34
|
+
/** The page's path inside its version (`guide/install`, `index`). */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Its version's settings. */
|
|
37
|
+
version?: VersionSeo;
|
|
38
|
+
/** The page's front matter `canonicalUrl`, when set. Only an absolute URL counts. */
|
|
39
|
+
frontmatter?: string;
|
|
40
|
+
/** The page's own absolute URL, when known: a `canonicalUrl` equal to it is the page itself. */
|
|
41
|
+
selfUrl?: string;
|
|
42
|
+
/** Whether the default version (same language) has a live page at this path. */
|
|
43
|
+
defaultHas: (path: string) => boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Where a redirect in the default version sends this path, chains
|
|
46
|
+
* collapsed: a path inside the version, a site path with a leading slash
|
|
47
|
+
* (outside the version: not followed), or a URL. Undefined when none.
|
|
48
|
+
*/
|
|
49
|
+
defaultRedirect?: (path: string) => string | undefined;
|
|
50
|
+
}
|
|
51
|
+
export type CanonicalTarget =
|
|
52
|
+
/** The page itself. */
|
|
53
|
+
{
|
|
54
|
+
kind: "self";
|
|
55
|
+
}
|
|
56
|
+
/** A page of the default version, by its path inside it. */
|
|
57
|
+
| {
|
|
58
|
+
kind: "default";
|
|
59
|
+
path: string;
|
|
60
|
+
}
|
|
61
|
+
/** An absolute URL, as written. */
|
|
62
|
+
| {
|
|
63
|
+
kind: "url";
|
|
64
|
+
url: string;
|
|
65
|
+
};
|
|
66
|
+
export interface CanonicalDecision {
|
|
67
|
+
target: CanonicalTarget;
|
|
68
|
+
/** List the page in the sitemap. */
|
|
69
|
+
sitemap: boolean;
|
|
70
|
+
/** Mark the page `noindex`. */
|
|
71
|
+
noindex: boolean;
|
|
72
|
+
/** Why the page's `canonicalUrl` was ignored, for the build's warnings. */
|
|
73
|
+
warning?: string;
|
|
74
|
+
}
|
|
75
|
+
export declare function decideCanonical(q: CanonicalQuery): CanonicalDecision;
|
|
76
|
+
/** Check every version's settings, and that the default version is not `noindex` (older pages would point at noindex pages). */
|
|
77
|
+
export declare function checkVersionsSeo(versions: Array<{
|
|
78
|
+
id: string;
|
|
79
|
+
} & Record<string, unknown>>, defaultVersion: string | undefined): string | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Check one version's settings; returns an error message or undefined.
|
|
82
|
+
* `noindex` with `sitemap: true` lists pages search engines are told to skip.
|
|
83
|
+
*/
|
|
84
|
+
export declare function checkVersionSeo(id: string, v: Record<string, unknown>): string | undefined;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which URL a page of a versioned site names as its canonical, and whether
|
|
3
|
+
* it goes in the sitemap (dogsbay-ssg plans/canonical-versions.md, step 2).
|
|
4
|
+
* Both pipelines call this per page and turn the result into their own
|
|
5
|
+
* URLs: a page of an older version points at the same page in the default
|
|
6
|
+
* version, so search engines index the current docs.
|
|
7
|
+
*
|
|
8
|
+
* | Page | Canonical | Sitemap |
|
|
9
|
+
* |---|---|---|
|
|
10
|
+
* | in the default version | itself | yes |
|
|
11
|
+
* | older, and the default version has the page | that page | no |
|
|
12
|
+
* | older, and a redirect in the default version covers its path | the redirect's target | no |
|
|
13
|
+
* | older, only in its version | itself | yes |
|
|
14
|
+
* | in a `noindex: true` version | itself (robots noindex) | no |
|
|
15
|
+
* | in a `canonical: self` version | itself | yes |
|
|
16
|
+
*
|
|
17
|
+
* `sitemap: true | false` on a version overrides the last column (with
|
|
18
|
+
* `noindex`, true is refused at config load). A page's own front matter
|
|
19
|
+
* `canonicalUrl` (an absolute URL) wins over all of it; a redirect is
|
|
20
|
+
* followed only when it ends on a page of the default version or on
|
|
21
|
+
* another site.
|
|
22
|
+
*/
|
|
23
|
+
const URL_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
24
|
+
const ABSOLUTE = /^https?:\/\//i;
|
|
25
|
+
const same = (a, b) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
26
|
+
export function decideCanonical(q) {
|
|
27
|
+
const v = q.version ?? {};
|
|
28
|
+
const self = (warning) => (v.noindex ? { target: { kind: "self" }, sitemap: false, noindex: true } : { target: { kind: "self" }, sitemap: v.sitemap ?? true, noindex: false, ...(warning ? { warning } : {}) });
|
|
29
|
+
const override = q.frontmatter?.trim();
|
|
30
|
+
let warning;
|
|
31
|
+
if (override) {
|
|
32
|
+
if (!ABSOLUTE.test(override)) {
|
|
33
|
+
warning = `canonicalUrl "${override}" is not an absolute URL (https://…); ignored`;
|
|
34
|
+
}
|
|
35
|
+
else if (q.selfUrl && same(override, q.selfUrl)) {
|
|
36
|
+
return self();
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// A page that names another page as canonical is not listed, unless its version says so.
|
|
40
|
+
return { target: { kind: "url", url: override }, sitemap: v.sitemap ?? false, noindex: !!v.noindex };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (v.noindex || q.isDefault || v.canonical === "self")
|
|
44
|
+
return self(warning);
|
|
45
|
+
const moved = (target) => ({ target, sitemap: v.sitemap ?? false, noindex: false, ...(warning ? { warning } : {}) });
|
|
46
|
+
if (q.defaultHas(q.path))
|
|
47
|
+
return moved({ kind: "default", path: q.path });
|
|
48
|
+
const r = q.defaultRedirect?.(q.path);
|
|
49
|
+
// Followed only to a page of the default version, or to another site: a redirect out of the
|
|
50
|
+
// version (to an archived copy) leaves the page on itself, and in the sitemap.
|
|
51
|
+
if (r !== undefined && URL_RE.test(r))
|
|
52
|
+
return moved({ kind: "url", url: r });
|
|
53
|
+
if (r !== undefined && !r.startsWith("/") && r !== q.path && q.defaultHas(r))
|
|
54
|
+
return moved({ kind: "default", path: r });
|
|
55
|
+
return self(warning);
|
|
56
|
+
}
|
|
57
|
+
/** Check every version's settings, and that the default version is not `noindex` (older pages would point at noindex pages). */
|
|
58
|
+
export function checkVersionsSeo(versions, defaultVersion) {
|
|
59
|
+
for (const v of versions) {
|
|
60
|
+
const bad = checkVersionSeo(v.id, v);
|
|
61
|
+
if (bad)
|
|
62
|
+
return bad;
|
|
63
|
+
if (v.id === defaultVersion && v.noindex === true)
|
|
64
|
+
return `versions: "${v.id}" is the defaultVersion and has noindex: true; older versions' pages name it as their canonical`;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check one version's settings; returns an error message or undefined.
|
|
70
|
+
* `noindex` with `sitemap: true` lists pages search engines are told to skip.
|
|
71
|
+
*/
|
|
72
|
+
export function checkVersionSeo(id, v) {
|
|
73
|
+
if (v.canonical !== undefined && v.canonical !== "self")
|
|
74
|
+
return `versions: "${id}".canonical must be "self" (or left out for the canonical-to-latest rule); got ${JSON.stringify(v.canonical)}`;
|
|
75
|
+
if (v.noindex !== undefined && typeof v.noindex !== "boolean")
|
|
76
|
+
return `versions: "${id}".noindex must be true or false`;
|
|
77
|
+
if (v.sitemap !== undefined && typeof v.sitemap !== "boolean")
|
|
78
|
+
return `versions: "${id}".sitemap must be true or false`;
|
|
79
|
+
if (v.noindex === true && v.sitemap === true)
|
|
80
|
+
return `versions: "${id}" has noindex: true and sitemap: true; a sitemap should not list pages search engines are told to skip`;
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hreflang` alternates for a page that exists in several languages: every
|
|
3
|
+
* copy lists all of them (itself included) and `x-default`, the default
|
|
4
|
+
* language's copy. Built the same way by dogsbay-ssg and the Astro pipeline;
|
|
5
|
+
* each renders `<link rel="alternate" hreflang=… href=…>`.
|
|
6
|
+
*/
|
|
7
|
+
export interface HreflangLink {
|
|
8
|
+
hreflang: string;
|
|
9
|
+
href: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The links for one page, from its copies by language (absolute URLs).
|
|
13
|
+
* Fewer than two languages: none. Sorted by language, `x-default` last, when
|
|
14
|
+
* the default language has the page.
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildHreflangLinks(alternates: {
|
|
17
|
+
lang: string;
|
|
18
|
+
url: string;
|
|
19
|
+
}[], defaultLang?: string): HreflangLink[];
|
package/dist/hreflang.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links for one page, from its copies by language (absolute URLs).
|
|
3
|
+
* Fewer than two languages: none. Sorted by language, `x-default` last, when
|
|
4
|
+
* the default language has the page.
|
|
5
|
+
*/
|
|
6
|
+
export function buildHreflangLinks(alternates, defaultLang) {
|
|
7
|
+
const byLang = new Map();
|
|
8
|
+
for (const a of alternates)
|
|
9
|
+
if (a.lang && a.url && !byLang.has(a.lang))
|
|
10
|
+
byLang.set(a.lang, a.url);
|
|
11
|
+
if (byLang.size < 2)
|
|
12
|
+
return [];
|
|
13
|
+
const links = [...byLang].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([hreflang, href]) => ({ hreflang, href }));
|
|
14
|
+
const def = defaultLang !== undefined ? byLang.get(defaultLang) : undefined;
|
|
15
|
+
if (def)
|
|
16
|
+
links.push({ hreflang: "x-default", href: def });
|
|
17
|
+
return links;
|
|
18
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dogsbay/site-files — the files a docs site ships beside its pages, built
|
|
3
|
+
* the same way by the Astro pipeline (format-astro) and dogsbay-ssg. Pure and
|
|
4
|
+
* Worker-safe: strings in, strings out. See plans/site-files-package.md.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./json-ld.js";
|
|
7
|
+
export * from "./text.js";
|
|
8
|
+
export * from "./robots.js";
|
|
9
|
+
export * from "./sitemap.js";
|
|
10
|
+
export * from "./llms.js";
|
|
11
|
+
export * from "./redirects.js";
|
|
12
|
+
export * from "./canonical.js";
|
|
13
|
+
export * from "./hreflang.js";
|
|
14
|
+
export * from "./translations.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dogsbay/site-files — the files a docs site ships beside its pages, built
|
|
3
|
+
* the same way by the Astro pipeline (format-astro) and dogsbay-ssg. Pure and
|
|
4
|
+
* Worker-safe: strings in, strings out. See plans/site-files-package.md.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./json-ld.js";
|
|
7
|
+
export * from "./text.js";
|
|
8
|
+
export * from "./robots.js";
|
|
9
|
+
export * from "./sitemap.js";
|
|
10
|
+
export * from "./llms.js";
|
|
11
|
+
export * from "./redirects.js";
|
|
12
|
+
export * from "./canonical.js";
|
|
13
|
+
export * from "./hreflang.js";
|
|
14
|
+
export * from "./translations.js";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD helpers — pure functions consumed by DocsLayout and dogsbay-ssg to
|
|
3
|
+
* decide which Schema.org `@type` to emit and to normalize the
|
|
4
|
+
* `customJsonLd` prop shape.
|
|
5
|
+
*
|
|
6
|
+
* The 100daysofdocs SEO audit flagged that DocsLayout was emitting
|
|
7
|
+
* `Article` for every page regardless of intent — tutorials and
|
|
8
|
+
* how-to pages should render as `HowTo` for SERP rich results.
|
|
9
|
+
* This module owns the type selection so it's testable without
|
|
10
|
+
* spinning up an Astro renderer.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Map a `meta.type` value (the open-string `pageType` prop on
|
|
14
|
+
* DocsLayout) to a Schema.org `@type`.
|
|
15
|
+
*
|
|
16
|
+
* The mapping intentionally accepts a few common spelling
|
|
17
|
+
* variants (`"how-to"` / `"howto"`, `"reference"` / `"ref"` /
|
|
18
|
+
* `"api"`) so writers don't have to learn one canonical spelling
|
|
19
|
+
* to get the right structured data. Anything else falls back to
|
|
20
|
+
* `Article`, which is Google's "general docs page" assumption.
|
|
21
|
+
*/
|
|
22
|
+
export declare function jsonLdTypeFor(pageType: string | undefined): string;
|
|
23
|
+
/**
|
|
24
|
+
* Normalize the `customJsonLd` prop into an array. DocsLayout
|
|
25
|
+
* accepts either a single object (most common case — one extra
|
|
26
|
+
* Course / Organization block) or an array (sites that layer
|
|
27
|
+
* several blocks per page).
|
|
28
|
+
*
|
|
29
|
+
* Returns an empty array for `undefined` so the template can
|
|
30
|
+
* iterate uniformly without conditional rendering.
|
|
31
|
+
*/
|
|
32
|
+
export declare function normalizeCustomJsonLd(raw: Record<string, unknown> | Record<string, unknown>[] | undefined): Record<string, unknown>[];
|
|
33
|
+
export interface BuildArticleJsonLdOptions {
|
|
34
|
+
/** Already-resolved Schema.org `@type` (output of jsonLdTypeFor). */
|
|
35
|
+
type: string;
|
|
36
|
+
title: string;
|
|
37
|
+
/** Site name — used as the `provider` Organization for Course. */
|
|
38
|
+
siteName: string;
|
|
39
|
+
keywords: string[];
|
|
40
|
+
description?: string;
|
|
41
|
+
image?: string;
|
|
42
|
+
url?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Page headings — used to synthesize `step[]` for HowTo. Each
|
|
45
|
+
* H2 (or H3 if no H2s exist) becomes a HowToStep with a deep
|
|
46
|
+
* link to the heading id.
|
|
47
|
+
*/
|
|
48
|
+
headings?: ReadonlyArray<{
|
|
49
|
+
depth: number;
|
|
50
|
+
slug: string;
|
|
51
|
+
text: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the JSON-LD payload for the page's primary structured-data
|
|
56
|
+
* block, shaped per `@type`. The earlier implementation emitted
|
|
57
|
+
* Article-shaped fields (`headline`) regardless of @type, which
|
|
58
|
+
* meant HowTo / Course pages failed Google's Rich Results
|
|
59
|
+
* requirements (HowTo needs `name` + `step`, Course needs `name` +
|
|
60
|
+
* `description` + `provider`). See
|
|
61
|
+
* `packages/site-check/src/rules/seo/json-ld-required-fields.ts`
|
|
62
|
+
* for the validator that catches this.
|
|
63
|
+
*
|
|
64
|
+
* Schema.org's `name` is a Thing-level field accepted by every
|
|
65
|
+
* @type, so we always emit it. `headline` is added on top for
|
|
66
|
+
* Article-family types (Article, TechArticle) because Google's
|
|
67
|
+
* Rich Results validator specifically requires it there. HowTo
|
|
68
|
+
* gets a synthesized `step[]` from the page's H2 headings (each
|
|
69
|
+
* H2 = one procedure step); Course gets a `provider` Organization
|
|
70
|
+
* built from `siteName`.
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildArticleJsonLd(opts: BuildArticleJsonLdOptions): Record<string, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* Identity structured data for a page that is not an article — a
|
|
75
|
+
* homepage, a landing page, a section index.
|
|
76
|
+
*
|
|
77
|
+
* Exists because the article block is gated on `ogType === "article"`,
|
|
78
|
+
* so those pages emitted NO structured data at all. An external agent
|
|
79
|
+
* audit reported "No JSON-LD structured data found on homepage", which
|
|
80
|
+
* was accurate: the one page most likely to be an agent's entry point
|
|
81
|
+
* was the one describing itself the least.
|
|
82
|
+
*
|
|
83
|
+
* `WebSite` rather than `Organization` or `SoftwareApplication` on
|
|
84
|
+
* purpose. Those are claims about what the site's OWNER is, and
|
|
85
|
+
* guessing wrong is worse than saying less — a docs site for a product
|
|
86
|
+
* is not itself the product. Owners wanting a stronger identity type
|
|
87
|
+
* add it through `customJsonLd`, which layers on top of this.
|
|
88
|
+
*/
|
|
89
|
+
export declare function buildWebSiteJsonLd(opts: {
|
|
90
|
+
siteName: string;
|
|
91
|
+
title: string;
|
|
92
|
+
description?: string;
|
|
93
|
+
url?: string;
|
|
94
|
+
}): Record<string, unknown>;
|
|
95
|
+
/**
|
|
96
|
+
* JSON-LD `BreadcrumbList` for a page: the nav trail from the top level down
|
|
97
|
+
* to the page, with absolute URLs. An entry without a URL (a section with no
|
|
98
|
+
* page of its own) is left out, since search engines require `item` on every
|
|
99
|
+
* entry but the last; positions count the entries kept. Fewer than two
|
|
100
|
+
* entries say nothing a breadcrumb adds: undefined.
|
|
101
|
+
*/
|
|
102
|
+
export declare function buildBreadcrumbJsonLd(items: {
|
|
103
|
+
name: string;
|
|
104
|
+
url?: string;
|
|
105
|
+
}[]): Record<string, unknown> | undefined;
|
package/dist/json-ld.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD helpers — pure functions consumed by DocsLayout and dogsbay-ssg to
|
|
3
|
+
* decide which Schema.org `@type` to emit and to normalize the
|
|
4
|
+
* `customJsonLd` prop shape.
|
|
5
|
+
*
|
|
6
|
+
* The 100daysofdocs SEO audit flagged that DocsLayout was emitting
|
|
7
|
+
* `Article` for every page regardless of intent — tutorials and
|
|
8
|
+
* how-to pages should render as `HowTo` for SERP rich results.
|
|
9
|
+
* This module owns the type selection so it's testable without
|
|
10
|
+
* spinning up an Astro renderer.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Map a `meta.type` value (the open-string `pageType` prop on
|
|
14
|
+
* DocsLayout) to a Schema.org `@type`.
|
|
15
|
+
*
|
|
16
|
+
* The mapping intentionally accepts a few common spelling
|
|
17
|
+
* variants (`"how-to"` / `"howto"`, `"reference"` / `"ref"` /
|
|
18
|
+
* `"api"`) so writers don't have to learn one canonical spelling
|
|
19
|
+
* to get the right structured data. Anything else falls back to
|
|
20
|
+
* `Article`, which is Google's "general docs page" assumption.
|
|
21
|
+
*/
|
|
22
|
+
export function jsonLdTypeFor(pageType) {
|
|
23
|
+
switch (pageType) {
|
|
24
|
+
case "tutorial":
|
|
25
|
+
case "how-to":
|
|
26
|
+
case "howto":
|
|
27
|
+
return "HowTo";
|
|
28
|
+
case "reference":
|
|
29
|
+
case "ref":
|
|
30
|
+
case "api":
|
|
31
|
+
return "TechArticle";
|
|
32
|
+
case "course":
|
|
33
|
+
return "Course";
|
|
34
|
+
default:
|
|
35
|
+
return "Article";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Normalize the `customJsonLd` prop into an array. DocsLayout
|
|
40
|
+
* accepts either a single object (most common case — one extra
|
|
41
|
+
* Course / Organization block) or an array (sites that layer
|
|
42
|
+
* several blocks per page).
|
|
43
|
+
*
|
|
44
|
+
* Returns an empty array for `undefined` so the template can
|
|
45
|
+
* iterate uniformly without conditional rendering.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeCustomJsonLd(raw) {
|
|
48
|
+
if (!raw)
|
|
49
|
+
return [];
|
|
50
|
+
if (Array.isArray(raw))
|
|
51
|
+
return raw;
|
|
52
|
+
return [raw];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the JSON-LD payload for the page's primary structured-data
|
|
56
|
+
* block, shaped per `@type`. The earlier implementation emitted
|
|
57
|
+
* Article-shaped fields (`headline`) regardless of @type, which
|
|
58
|
+
* meant HowTo / Course pages failed Google's Rich Results
|
|
59
|
+
* requirements (HowTo needs `name` + `step`, Course needs `name` +
|
|
60
|
+
* `description` + `provider`). See
|
|
61
|
+
* `packages/site-check/src/rules/seo/json-ld-required-fields.ts`
|
|
62
|
+
* for the validator that catches this.
|
|
63
|
+
*
|
|
64
|
+
* Schema.org's `name` is a Thing-level field accepted by every
|
|
65
|
+
* @type, so we always emit it. `headline` is added on top for
|
|
66
|
+
* Article-family types (Article, TechArticle) because Google's
|
|
67
|
+
* Rich Results validator specifically requires it there. HowTo
|
|
68
|
+
* gets a synthesized `step[]` from the page's H2 headings (each
|
|
69
|
+
* H2 = one procedure step); Course gets a `provider` Organization
|
|
70
|
+
* built from `siteName`.
|
|
71
|
+
*/
|
|
72
|
+
export function buildArticleJsonLd(opts) {
|
|
73
|
+
const { type, title, siteName, keywords, description, image, url, headings } = opts;
|
|
74
|
+
const block = {
|
|
75
|
+
"@context": "https://schema.org",
|
|
76
|
+
"@type": type,
|
|
77
|
+
name: title,
|
|
78
|
+
};
|
|
79
|
+
// Only when there are any. An empty `keywords: ""` asserts the page
|
|
80
|
+
// has no topics, which is a different claim from staying silent.
|
|
81
|
+
if (keywords.length > 0)
|
|
82
|
+
block.keywords = keywords.join(", ");
|
|
83
|
+
if (type === "Article" || type === "TechArticle") {
|
|
84
|
+
block.headline = title;
|
|
85
|
+
}
|
|
86
|
+
if (description)
|
|
87
|
+
block.description = description;
|
|
88
|
+
if (image)
|
|
89
|
+
block.image = image;
|
|
90
|
+
if (url)
|
|
91
|
+
block.url = url;
|
|
92
|
+
if (type === "HowTo") {
|
|
93
|
+
let stepHeadings = (headings ?? []).filter((h) => h.depth === 2);
|
|
94
|
+
if (stepHeadings.length === 0) {
|
|
95
|
+
stepHeadings = (headings ?? []).filter((h) => h.depth === 3);
|
|
96
|
+
}
|
|
97
|
+
block.step = stepHeadings.map((h) => ({
|
|
98
|
+
"@type": "HowToStep",
|
|
99
|
+
name: h.text,
|
|
100
|
+
url: url ? `${url}#${h.slug}` : `#${h.slug}`,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
if (type === "Course") {
|
|
104
|
+
block.provider = { "@type": "Organization", name: siteName };
|
|
105
|
+
if (!block.description)
|
|
106
|
+
block.description = title;
|
|
107
|
+
}
|
|
108
|
+
return block;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Identity structured data for a page that is not an article — a
|
|
112
|
+
* homepage, a landing page, a section index.
|
|
113
|
+
*
|
|
114
|
+
* Exists because the article block is gated on `ogType === "article"`,
|
|
115
|
+
* so those pages emitted NO structured data at all. An external agent
|
|
116
|
+
* audit reported "No JSON-LD structured data found on homepage", which
|
|
117
|
+
* was accurate: the one page most likely to be an agent's entry point
|
|
118
|
+
* was the one describing itself the least.
|
|
119
|
+
*
|
|
120
|
+
* `WebSite` rather than `Organization` or `SoftwareApplication` on
|
|
121
|
+
* purpose. Those are claims about what the site's OWNER is, and
|
|
122
|
+
* guessing wrong is worse than saying less — a docs site for a product
|
|
123
|
+
* is not itself the product. Owners wanting a stronger identity type
|
|
124
|
+
* add it through `customJsonLd`, which layers on top of this.
|
|
125
|
+
*/
|
|
126
|
+
export function buildWebSiteJsonLd(opts) {
|
|
127
|
+
const block = {
|
|
128
|
+
"@context": "https://schema.org",
|
|
129
|
+
"@type": "WebSite",
|
|
130
|
+
name: opts.siteName,
|
|
131
|
+
};
|
|
132
|
+
if (opts.title && opts.title !== opts.siteName)
|
|
133
|
+
block.headline = opts.title;
|
|
134
|
+
if (opts.description)
|
|
135
|
+
block.description = opts.description;
|
|
136
|
+
if (opts.url)
|
|
137
|
+
block.url = opts.url;
|
|
138
|
+
return block;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* JSON-LD `BreadcrumbList` for a page: the nav trail from the top level down
|
|
142
|
+
* to the page, with absolute URLs. An entry without a URL (a section with no
|
|
143
|
+
* page of its own) is left out, since search engines require `item` on every
|
|
144
|
+
* entry but the last; positions count the entries kept. Fewer than two
|
|
145
|
+
* entries say nothing a breadcrumb adds: undefined.
|
|
146
|
+
*/
|
|
147
|
+
export function buildBreadcrumbJsonLd(items) {
|
|
148
|
+
const kept = items.filter((it, i) => it.url || i === items.length - 1);
|
|
149
|
+
if (kept.length < 2)
|
|
150
|
+
return undefined;
|
|
151
|
+
return {
|
|
152
|
+
"@context": "https://schema.org",
|
|
153
|
+
"@type": "BreadcrumbList",
|
|
154
|
+
itemListElement: kept.map((it, i) => ({
|
|
155
|
+
"@type": "ListItem",
|
|
156
|
+
position: i + 1,
|
|
157
|
+
name: it.name,
|
|
158
|
+
...(it.url ? { item: it.url } : {}),
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
}
|
package/dist/llms.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llms.txt, the per-section llms.txt files and llms-full.txt (llmstxt.org).
|
|
3
|
+
*
|
|
4
|
+
* The pipelines pass small records, not their page models: each page's key
|
|
5
|
+
* (its slug), title and description (already cut with `truncate`), and the
|
|
6
|
+
* nav as keys. They also pass `url(key)`, the link an entry gets: Astro's
|
|
7
|
+
* `/docs/x.md` (absolute from the origin with `site.url`), dogsbay-ssg's
|
|
8
|
+
* `x.md` relative to the file or absolute. Page bodies are never held here:
|
|
9
|
+
* llms-full.txt reads each through `load(key)` and streams.
|
|
10
|
+
*/
|
|
11
|
+
export interface LlmsSite {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
/** "When to use this": what the site is good for. Omitted when unset. */
|
|
15
|
+
whenToUse?: string;
|
|
16
|
+
/** Other sites on this host, linked at the end of llms.txt (`site.aggregates`). */
|
|
17
|
+
aggregates?: LlmsAggregate[];
|
|
18
|
+
}
|
|
19
|
+
export interface LlmsAggregate {
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
llmsTxt?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface LlmsPage {
|
|
26
|
+
title: string;
|
|
27
|
+
/** Shown after the link, as given (cut it with `truncate`). */
|
|
28
|
+
description?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface LlmsNav {
|
|
31
|
+
label: string;
|
|
32
|
+
/** The page's key; absent for a label-only group. */
|
|
33
|
+
key?: string;
|
|
34
|
+
/** Appended to the entry's URL (`#section`). */
|
|
35
|
+
fragment?: string;
|
|
36
|
+
children: LlmsNav[];
|
|
37
|
+
}
|
|
38
|
+
export interface LlmsOptions {
|
|
39
|
+
/** The URL of a page's entry. */
|
|
40
|
+
url(key: string): string;
|
|
41
|
+
/** A nav entry whose key has no page: `"skip"` it (dogsbay-ssg) or `"link"` it by its label (Astro). Default `"skip"`. */
|
|
42
|
+
missing?: "skip" | "link";
|
|
43
|
+
/** Escape `[` and `]` in labels, which would end the link text early (dogsbay-ssg). Default false. */
|
|
44
|
+
escapeLabels?: boolean;
|
|
45
|
+
/** Heading for top-level pages outside a group. Default "Documentation". */
|
|
46
|
+
topLevelLabel?: string;
|
|
47
|
+
/** Heading for pages not in the nav, or false to leave them out. Default "Other pages". */
|
|
48
|
+
otherPagesLabel?: string | false;
|
|
49
|
+
/** Heading for `site.aggregates`. Default "Other sites". */
|
|
50
|
+
aggregatesLabel?: string;
|
|
51
|
+
}
|
|
52
|
+
type Pages = ReadonlyMap<string, LlmsPage>;
|
|
53
|
+
/**
|
|
54
|
+
* The site's llms.txt: the title, description and "When to use this", then
|
|
55
|
+
* top-level pages under "Documentation", one section per top-level nav group,
|
|
56
|
+
* pages outside the nav under "Other pages" (sorted by key), and other sites.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildLlmsTxt(site: LlmsSite, nav: LlmsNav[], pages: Pages, o: LlmsOptions): string;
|
|
59
|
+
/** One top-level nav group's own llms.txt. */
|
|
60
|
+
export declare function buildSectionLlmsTxt(site: Pick<LlmsSite, "name">, group: LlmsNav, pages: Pages, o: LlmsOptions): string;
|
|
61
|
+
/** Page keys in nav order, then pages outside the nav sorted by key: the llms-full.txt order. */
|
|
62
|
+
export declare function llmsFullOrder(nav: LlmsNav[], pages: Pages): string[];
|
|
63
|
+
/**
|
|
64
|
+
* llms-full.txt, one chunk per page so the file is never held whole: the
|
|
65
|
+
* header, then for each page `# Title`, `URL:` and its Markdown (from
|
|
66
|
+
* `load`), separated by `---`. Joined, the chunks equal `buildLlmsFullTxt`.
|
|
67
|
+
* `site.whenToUse` is written when given (neither pipeline passes it).
|
|
68
|
+
*/
|
|
69
|
+
export declare function llmsFullChunks(site: LlmsSite, order: string[], pages: Pages, url: (key: string) => string, load: (key: string) => string | Promise<string>): AsyncGenerator<string>;
|
|
70
|
+
/** llms-full.txt as one string, for a caller that has every page at hand. */
|
|
71
|
+
export declare function buildLlmsFullTxt(site: LlmsSite, order: string[], pages: Pages, url: (key: string) => string, load: (key: string) => string): string;
|
|
72
|
+
export {};
|