@jdevalk/astro-seo-graph 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,14 +2,13 @@
2
2
 
3
3
  Astro integration for [`@jdevalk/seo-graph-core`](../seo-graph-core). Ships a
4
4
  `<Seo>` component, route factories for agent-ready schema endpoints, a
5
- content-collection aggregator, and Zod helpers for content schemas.
5
+ content-collection aggregator, breadcrumb helpers, and Zod helpers for content
6
+ schemas.
6
7
 
7
- > **Status:** `0.1.0` (pre-1.0). The API works and has two real consumers
8
- > in production (joost.blog and limonaia.house), but a few known warts in
9
- > the core will be smoothed out in `0.2.x` without breaking changes. See
10
- > `@jdevalk/seo-graph-core`'s README for the full list.
8
+ For detailed usage including all builder signatures, site-type recipes, and
9
+ schema.org best practices see [AGENTS.md](https://github.com/jdevalk/seo-graph/blob/main/AGENTS.md).
11
10
 
12
- ## What's in v0.1
11
+ ## What you get
13
12
 
14
13
  | API | Purpose |
15
14
  | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -20,12 +19,7 @@ content-collection aggregator, and Zod helpers for content schemas.
20
19
  | **`seoSchema`, `imageSchema`** | Zod schemas for the `seo` and `image` fields on content collections. Import them into `src/content.config.ts`. |
21
20
  | **`buildAstroSeoProps`** | Pure-TS logic that powers `<Seo>` — exported for users who want to feed a different head component. |
22
21
  | **`buildAlternateLinks`** | Pure helper that turns a `{ hreflang, href }` entry list into normalized `<link rel="alternate">` tags plus an `x-default`. Used internally by `<Seo>`'s `alternates` prop, and exported for non-Astro callers (e.g. CMS plugins feeding their own metadata pipelines). |
23
-
24
- ## Not in `0.1.x` (coming in `0.2.x`)
25
-
26
- - **`createOgRenderer`** — a themeable `satori` + `sharp` wrapper for generating
27
- Open Graph images at build time. Pulled out of `0.1` to keep the dep tree
28
- free of native binaries. Keep using your own `og-image.ts` for now.
22
+ | **`breadcrumbsFromUrl`** | Derives a breadcrumb trail from an Astro URL. Splits path segments, supports custom display names and segment skipping. Returns `BreadcrumbItem[]` ready to pass to `buildBreadcrumbList`. |
29
23
 
30
24
  ## Installation
31
25
 
@@ -78,6 +72,41 @@ const graph = buildSchemaGraph({
78
72
  </html>
79
73
  ```
80
74
 
75
+ ## Breadcrumbs from URL
76
+
77
+ Derive a breadcrumb trail from an Astro page URL instead of computing it
78
+ manually:
79
+
80
+ ```ts
81
+ import { breadcrumbsFromUrl } from '@jdevalk/astro-seo-graph';
82
+ import { buildBreadcrumbList, makeIds } from '@jdevalk/seo-graph-core';
83
+
84
+ const ids = makeIds({ siteUrl: 'https://example.com' });
85
+ const url = 'https://example.com/blog/open-source/my-post/';
86
+
87
+ const items = breadcrumbsFromUrl({
88
+ url: Astro.url, // or any URL / string
89
+ siteUrl: 'https://example.com',
90
+ pageName: 'My Post', // display name for the current page
91
+ // homeName: 'Home', // optional, defaults to 'Home'
92
+ // names: { blog: 'Articles' }, // optional, custom segment names
93
+ // skip: ['category'], // optional, segments to omit
94
+ });
95
+
96
+ const breadcrumb = buildBreadcrumbList({ url, items }, ids);
97
+ // items === [
98
+ // { name: 'Home', url: 'https://example.com/' },
99
+ // { name: 'Blog', url: 'https://example.com/blog/' },
100
+ // { name: 'Open Source', url: 'https://example.com/blog/open-source/' },
101
+ // { name: 'My Post', url: 'https://example.com/blog/open-source/my-post/' },
102
+ // ]
103
+ ```
104
+
105
+ Segments without a `names` entry are title-cased from their slug
106
+ (`open-source` → `Open Source`). Sites with a base path
107
+ (e.g. `https://example.com/docs`) are supported — pass the base path as part
108
+ of `siteUrl`.
109
+
81
110
  ## hreflang alternates
82
111
 
83
112
  For multilingual sites, pass an `alternates` prop with one entry per locale.
@@ -181,6 +210,7 @@ export const GET = createSchemaEndpoint({
181
210
  datePublished: post.data.publishDate,
182
211
  },
183
212
  ids,
213
+ 'BlogPosting',
184
214
  ),
185
215
  ];
186
216
  },
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Breadcrumb derivation helper for Astro pages.
3
+ *
4
+ * Pure function: no DOM, no fetch, no Astro runtime. Takes a page URL
5
+ * (typically `Astro.url`) and derives an ordered breadcrumb trail from
6
+ * its path segments. The returned `BreadcrumbItem[]` array is passed
7
+ * straight to `buildBreadcrumbList` from `@jdevalk/seo-graph-core`.
8
+ *
9
+ * Callers control display names via the `names` map and can omit
10
+ * segments via `skip`. Segments without a mapped name are title-cased
11
+ * from their slug (e.g. `open-source` → `Open Source`).
12
+ */
13
+ import type { BreadcrumbItem } from '@jdevalk/seo-graph-core';
14
+ export interface BreadcrumbsFromUrlInput {
15
+ /**
16
+ * The full page URL. Typically `Astro.url` inside a layout, or any
17
+ * absolute URL string.
18
+ */
19
+ url: URL | string;
20
+ /**
21
+ * Site origin with optional base path, e.g. `'https://example.com'`
22
+ * or `'https://example.com/docs'`. Used to construct absolute URLs
23
+ * for each crumb. Must not end with a slash.
24
+ */
25
+ siteUrl: string;
26
+ /** Display name for the current (last) page. */
27
+ pageName: string;
28
+ /**
29
+ * Display name for the root crumb. Defaults to `'Home'`.
30
+ */
31
+ homeName?: string;
32
+ /**
33
+ * Map of path segments to display names. Keys are individual slug
34
+ * segments (e.g. `'blog'`, `'open-source'`). Segments not in this
35
+ * map are title-cased from their slug.
36
+ */
37
+ names?: Readonly<Record<string, string>>;
38
+ /**
39
+ * Segments to exclude from the breadcrumb trail. The pages they
40
+ * point to are still valid URLs — they just won't appear as crumbs.
41
+ * For example, `['category']` skips a `/blog/category/` crumb while
42
+ * still including `/blog/category/open-source/`.
43
+ */
44
+ skip?: readonly string[];
45
+ }
46
+ /**
47
+ * Derive a breadcrumb trail from a page URL.
48
+ *
49
+ * Always includes a root ("Home") crumb and the current page as the
50
+ * last crumb. Intermediate crumbs are derived from path segments
51
+ * between root and the page, skipping any segments listed in `skip`.
52
+ *
53
+ * @returns Ordered `BreadcrumbItem[]`, root first. Pass directly to
54
+ * `buildBreadcrumbList`'s `items` field.
55
+ */
56
+ export declare function breadcrumbsFromUrl(input: BreadcrumbsFromUrlInput): BreadcrumbItem[];
57
+ //# sourceMappingURL=breadcrumbs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"breadcrumbs.d.ts","sourceRoot":"","sources":["../src/breadcrumbs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D,MAAM,WAAW,uBAAuB;IACpC;;;OAGG;IACH,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC;IAElB;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;IAEhB,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IAEjB;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;OAIG;IACH,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAEzC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AAqBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,GAAG,cAAc,EAAE,CAkCnF"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Breadcrumb derivation helper for Astro pages.
3
+ *
4
+ * Pure function: no DOM, no fetch, no Astro runtime. Takes a page URL
5
+ * (typically `Astro.url`) and derives an ordered breadcrumb trail from
6
+ * its path segments. The returned `BreadcrumbItem[]` array is passed
7
+ * straight to `buildBreadcrumbList` from `@jdevalk/seo-graph-core`.
8
+ *
9
+ * Callers control display names via the `names` map and can omit
10
+ * segments via `skip`. Segments without a mapped name are title-cased
11
+ * from their slug (e.g. `open-source` → `Open Source`).
12
+ */
13
+ /**
14
+ * Title-case a URL slug: split on hyphens, capitalize each word.
15
+ *
16
+ * `'open-source'` → `'Open Source'`
17
+ */
18
+ function titleCaseSlug(slug) {
19
+ return slug
20
+ .split('-')
21
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
22
+ .join(' ');
23
+ }
24
+ /**
25
+ * Ensure `url` ends with exactly one trailing slash.
26
+ */
27
+ function trailingSlash(url) {
28
+ return url.endsWith('/') ? url : url + '/';
29
+ }
30
+ /**
31
+ * Derive a breadcrumb trail from a page URL.
32
+ *
33
+ * Always includes a root ("Home") crumb and the current page as the
34
+ * last crumb. Intermediate crumbs are derived from path segments
35
+ * between root and the page, skipping any segments listed in `skip`.
36
+ *
37
+ * @returns Ordered `BreadcrumbItem[]`, root first. Pass directly to
38
+ * `buildBreadcrumbList`'s `items` field.
39
+ */
40
+ export function breadcrumbsFromUrl(input) {
41
+ const { pageName, homeName = 'Home', names = {}, skip = [] } = input;
42
+ const normalizedSiteUrl = trailingSlash(input.siteUrl);
43
+ const pageUrl = typeof input.url === 'string' ? new URL(input.url) : input.url;
44
+ const pageHref = trailingSlash(pageUrl.href);
45
+ // Derive the path relative to the site origin.
46
+ const siteOrigin = new URL(normalizedSiteUrl);
47
+ const relativePath = pageUrl.pathname.slice(siteOrigin.pathname.length);
48
+ const segments = relativePath.split('/').filter(Boolean);
49
+ // When the page is the site root, there is only one crumb.
50
+ if (segments.length === 0) {
51
+ return [{ name: pageName, url: pageHref }];
52
+ }
53
+ const skipSet = new Set(skip);
54
+ const items = [{ name: homeName, url: normalizedSiteUrl }];
55
+ // Build intermediate crumbs (everything except the last segment,
56
+ // which is the current page).
57
+ let accumulated = siteOrigin.pathname;
58
+ for (const segment of segments.slice(0, -1)) {
59
+ accumulated = trailingSlash(accumulated + segment);
60
+ if (skipSet.has(segment))
61
+ continue;
62
+ const name = names[segment] ?? titleCaseSlug(segment);
63
+ items.push({ name, url: siteOrigin.origin + accumulated });
64
+ }
65
+ // Current page is always the last crumb.
66
+ items.push({ name: pageName, url: pageHref });
67
+ return items;
68
+ }
69
+ //# sourceMappingURL=breadcrumbs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"breadcrumbs.js","sourceRoot":"","sources":["../src/breadcrumbs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AA0CH;;;;GAIG;AACH,SAAS,aAAa,CAAC,IAAY;IAC/B,OAAO,IAAI;SACN,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;SAC3D,IAAI,CAAC,GAAG,CAAC,CAAC;AACnB,CAAC;AAED;;GAEG;AACH,SAAS,aAAa,CAAC,GAAW;IAC9B,OAAO,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,CAAC;AAC/C,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAA8B;IAC7D,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,MAAM,EAAE,KAAK,GAAG,EAAE,EAAE,IAAI,GAAG,EAAE,EAAE,GAAG,KAAK,CAAC;IAErE,MAAM,iBAAiB,GAAG,aAAa,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACvD,MAAM,OAAO,GAAG,OAAO,KAAK,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC;IAC/E,MAAM,QAAQ,GAAG,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAE7C,+CAA+C;IAC/C,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAC9C,MAAM,YAAY,GAAG,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACxE,MAAM,QAAQ,GAAG,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAEzD,2DAA2D;IAC3D,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;IAC/C,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC;IAC9B,MAAM,KAAK,GAAqB,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,iBAAiB,EAAE,CAAC,CAAC;IAE7E,iEAAiE;IACjE,8BAA8B;IAC9B,IAAI,WAAW,GAAG,UAAU,CAAC,QAAQ,CAAC;IACtC,KAAK,MAAM,OAAO,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1C,WAAW,GAAG,aAAa,CAAC,WAAW,GAAG,OAAO,CAAC,CAAC;QACnD,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,SAAS;QACnC,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,aAAa,CAAC,OAAO,CAAC,CAAC;QACtD,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,CAAC,MAAM,GAAG,WAAW,EAAE,CAAC,CAAC;IAC/D,CAAC;IAED,yCAAyC;IACzC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;IAE9C,OAAO,KAAK,CAAC;AACjB,CAAC"}
package/dist/index.d.ts CHANGED
@@ -7,4 +7,6 @@ export { buildAstroSeoProps } from './components/seo-props.js';
7
7
  export type { SeoProps, AstroSeoProps } from './components/seo-props.js';
8
8
  export { buildAlternateLinks } from './alternates.js';
9
9
  export type { AlternateLink, BuildAlternateLinksInput } from './alternates.js';
10
+ export { breadcrumbsFromUrl } from './breadcrumbs.js';
11
+ export type { BreadcrumbsFromUrlInput } from './breadcrumbs.js';
10
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAE1E,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACpE,YAAY,EAAE,qBAAqB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE3F,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAE9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAEzE,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,YAAY,EAAE,aAAa,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAE1E,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACpE,YAAY,EAAE,qBAAqB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE3F,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAE9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAEzE,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,YAAY,EAAE,aAAa,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAE/E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,YAAY,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -8,4 +8,5 @@ export { createSchemaEndpoint, createSchemaMap } from './routes.js';
8
8
  export { seoSchema, imageSchema } from './content-helpers.js';
9
9
  export { buildAstroSeoProps } from './components/seo-props.js';
10
10
  export { buildAlternateLinks } from './alternates.js';
11
+ export { breadcrumbsFromUrl } from './breadcrumbs.js';
11
12
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,sDAAsD;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAG5C,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGpE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAE9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAG/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,sDAAsD;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAG5C,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGpE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAE9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAG/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAGtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jdevalk/astro-seo-graph",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Astro integration for @jdevalk/seo-graph-core. Seo component, route factories, content-collection aggregator, Zod content helpers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -27,6 +27,7 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "README.md",
30
+ "AGENTS.md",
30
31
  "LICENSE"
31
32
  ],
32
33
  "repository": {
@@ -46,7 +47,7 @@
46
47
  "astro-seo": "^1.1.0",
47
48
  "schema-dts": "^2.0.0",
48
49
  "zod": "^3.24.0",
49
- "@jdevalk/seo-graph-core": "0.3.0"
50
+ "@jdevalk/seo-graph-core": "0.4.1"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@types/node": "^22.0.0",
@@ -55,7 +56,7 @@
55
56
  "vitest": "^2.0.0"
56
57
  },
57
58
  "scripts": {
58
- "build": "tsc -p tsconfig.build.json && mkdir -p dist/components && cp src/components/*.astro dist/components/",
59
+ "build": "tsc -p tsconfig.build.json && mkdir -p dist/components && cp src/components/*.astro dist/components/ && cp ../../AGENTS.md .",
59
60
  "typecheck": "tsc -p tsconfig.json",
60
61
  "test": "vitest run"
61
62
  }