@ampless/plugin-seo 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ampless contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @ampless/plugin-seo
2
+
3
+ SEO plugin for [ampless](https://github.com/heavymoons/ampless). Generates per-post and per-site metadata (Open Graph, Twitter card, canonical) and keeps a `sitemap.xml` regenerated to S3 whenever the post set changes.
4
+
5
+ > **Pre-release / alpha.** Breaking changes possible in any minor version until v1.0.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @ampless/plugin-seo@alpha
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ In `cms.config.ts`:
16
+
17
+ ```ts
18
+ import { defineConfig } from 'ampless'
19
+ import seoPlugin from '@ampless/plugin-seo'
20
+
21
+ export default defineConfig({
22
+ // ...
23
+ plugins: [
24
+ seoPlugin({
25
+ // defaultOgImage: 'https://example.com/og-default.png',
26
+ // twitterSite: '@example',
27
+ // twitterCreator: '@author',
28
+ // twitterCard: 'summary_large_image',
29
+ }),
30
+ ],
31
+ })
32
+ ```
33
+
34
+ | Option | Default | Notes |
35
+ |---|---|---|
36
+ | `defaultOgImage` | none | Falls through to `og:image` and `twitter:image` for every post when set |
37
+ | `twitterSite` | none | `@handle` of the site |
38
+ | `twitterCreator` | none | `@handle` of the post author |
39
+ | `twitterCard` | `'summary_large_image'` | Card style |
40
+ | `siteUrl` | `site.url` | Override base URL (e.g. for staging) |
41
+ | `priority` / `changefreq` / `limit` | (sitemap defaults) | Sitemap entry tuning |
42
+
43
+ ## What it produces
44
+
45
+ - **Per-post metadata** (Next.js `generateMetadata` shape): `title`, `description`, `alternates.canonical`, `openGraph` (article type, url, images), `twitter` (card, handles, images)
46
+ - **Site-level metadata**: defaults for the root layout — title, description, og:website
47
+ - **`/sitemap.xml`** — full URL set, regenerated to `s3://<bucket>/public/plugins/seo/sitemap.xml` on every `content.published` / `content.unpublished` / `content.deleted` / `content.updated` event. Served by the template's `/sitemap.xml` route handler.
48
+
49
+ ## Trust level
50
+
51
+ `trusted` — the sitemap regeneration runs in the trusted Lambda processor with read access to the post table and write access under `public/plugins/seo/` in the site's S3 bucket. The metadata helpers (`metadata` / `siteMetadata`) are pure functions that run during Next.js SSR and do not need any AWS access.
52
+
53
+ ## License
54
+
55
+ [MIT](../../LICENSE)
@@ -0,0 +1,41 @@
1
+ import { Post, Config, PluginMetadata, AmplessPlugin } from 'ampless';
2
+
3
+ interface SitemapOptions {
4
+ /** Maximum number of URLs to include. Default 5000. */
5
+ limit?: number;
6
+ /** Override base URL (defaults to config.site.url). */
7
+ siteUrl?: string;
8
+ /** Default changefreq for post URLs. */
9
+ changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
10
+ /** Default priority (0..1) for post URLs. */
11
+ priority?: number;
12
+ }
13
+ declare function buildSitemap(posts: Post[], site: Config['site'], options?: SitemapOptions): string;
14
+
15
+ interface SeoMetadataOptions {
16
+ /** Default OGP image URL when a post has no image override. */
17
+ defaultOgImage?: string;
18
+ /** Twitter site handle, e.g. "@example". */
19
+ twitterSite?: string;
20
+ /** Twitter creator handle, e.g. "@author". */
21
+ twitterCreator?: string;
22
+ /** OGP card type for post pages. Default 'summary_large_image'. */
23
+ twitterCard?: 'summary' | 'summary_large_image';
24
+ }
25
+ declare function buildPostMetadata(post: Post, site: Config['site'], options?: SeoMetadataOptions): PluginMetadata;
26
+ declare function buildSiteMetadata(site: Config['site'], options?: SeoMetadataOptions): PluginMetadata;
27
+
28
+ interface SeoPluginOptions extends SitemapOptions, SeoMetadataOptions {
29
+ }
30
+ /**
31
+ * SEO plugin: regenerates `sitemap.xml` whenever a post's published state
32
+ * changes, and provides Next.js `generateMetadata`-compatible per-post and
33
+ * per-site metadata (title, description, OGP, Twitter card, canonical).
34
+ *
35
+ * Lambda side stores the sitemap at
36
+ * `s3://{bucket}/public/plugins/seo/sitemap.xml`. The Next.js
37
+ * `/sitemap.xml` route streams that object through.
38
+ */
39
+ declare function seoPlugin(options?: SeoPluginOptions): AmplessPlugin;
40
+
41
+ export { type SeoMetadataOptions, type SeoPluginOptions, type SitemapOptions, buildPostMetadata, buildSiteMetadata, buildSitemap, seoPlugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ // src/index.ts
2
+ import { definePlugin } from "ampless";
3
+
4
+ // src/sitemap.ts
5
+ import { escapeXml } from "ampless";
6
+ var toIsoDate = (iso) => {
7
+ const d = new Date(iso);
8
+ return Number.isNaN(d.getTime()) ? "" : d.toISOString();
9
+ };
10
+ function buildSitemap(posts, site, options = {}) {
11
+ const baseUrl = (options.siteUrl ?? site.url).replace(/\/$/, "");
12
+ const limit = options.limit ?? 5e3;
13
+ const changefreq = options.changefreq ?? "weekly";
14
+ const priority = options.priority ?? 0.7;
15
+ const homeEntry = ` <url>
16
+ <loc>${escapeXml(baseUrl + "/")}</loc>
17
+ <changefreq>daily</changefreq>
18
+ <priority>1.0</priority>
19
+ </url>`;
20
+ const postEntries = posts.filter((p) => p.status === "published").slice(0, limit).map((post) => {
21
+ const url = `${baseUrl}/${post.slug}`;
22
+ const lastmod = post.publishedAt ? toIsoDate(post.publishedAt) : "";
23
+ const lastmodTag = lastmod ? `
24
+ <lastmod>${lastmod}</lastmod>` : "";
25
+ return ` <url>
26
+ <loc>${escapeXml(url)}</loc>${lastmodTag}
27
+ <changefreq>${changefreq}</changefreq>
28
+ <priority>${priority}</priority>
29
+ </url>`;
30
+ }).join("\n");
31
+ return `<?xml version="1.0" encoding="UTF-8"?>
32
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
33
+ ${homeEntry}
34
+ ${postEntries}
35
+ </urlset>
36
+ `;
37
+ }
38
+
39
+ // src/metadata.ts
40
+ function buildPostMetadata(post, site, options = {}) {
41
+ const baseUrl = site.url.replace(/\/$/, "");
42
+ const url = `${baseUrl}/${post.slug}`;
43
+ const description = post.excerpt ?? site.description ?? "";
44
+ const ogImage = options.defaultOgImage;
45
+ return {
46
+ title: post.title,
47
+ description,
48
+ openGraph: {
49
+ title: post.title,
50
+ description,
51
+ type: "article",
52
+ url,
53
+ ...ogImage && { images: [{ url: ogImage }] }
54
+ },
55
+ twitter: {
56
+ card: options.twitterCard ?? "summary_large_image",
57
+ title: post.title,
58
+ description,
59
+ ...options.twitterSite && { site: options.twitterSite },
60
+ ...options.twitterCreator && { creator: options.twitterCreator },
61
+ ...ogImage && { images: [ogImage] }
62
+ },
63
+ alternates: { canonical: url }
64
+ };
65
+ }
66
+ function buildSiteMetadata(site, options = {}) {
67
+ const description = site.description ?? site.name;
68
+ return {
69
+ title: site.name,
70
+ description,
71
+ openGraph: {
72
+ title: site.name,
73
+ description,
74
+ type: "website",
75
+ url: site.url,
76
+ ...options.defaultOgImage && { images: [{ url: options.defaultOgImage }] }
77
+ },
78
+ twitter: {
79
+ card: "summary",
80
+ title: site.name,
81
+ description,
82
+ ...options.twitterSite && { site: options.twitterSite }
83
+ }
84
+ };
85
+ }
86
+
87
+ // src/index.ts
88
+ function seoPlugin(options = {}) {
89
+ async function rebuild(_event, ctx) {
90
+ const posts = await ctx.listPublishedPosts();
91
+ const xml = buildSitemap(posts, ctx.site, options);
92
+ await ctx.writePublicAsset("sitemap.xml", xml, "application/xml; charset=utf-8");
93
+ }
94
+ return definePlugin({
95
+ name: "seo",
96
+ apiVersion: 1,
97
+ trust_level: "trusted",
98
+ hooks: {
99
+ "content.published": rebuild,
100
+ "content.unpublished": rebuild,
101
+ "content.deleted": rebuild,
102
+ // Updates can change title/slug of an already-published post, so we
103
+ // regenerate on every update too. listPublishedPosts() filters
104
+ // drafts, so an update on a draft just produces the same XML.
105
+ "content.updated": rebuild
106
+ },
107
+ metadata(post, site) {
108
+ return buildPostMetadata(post, site, options);
109
+ },
110
+ siteMetadata(site) {
111
+ return buildSiteMetadata(site, options);
112
+ }
113
+ });
114
+ }
115
+ export {
116
+ buildPostMetadata,
117
+ buildSiteMetadata,
118
+ buildSitemap,
119
+ seoPlugin as default
120
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@ampless/plugin-seo",
3
+ "version": "0.2.0-alpha.0",
4
+ "description": "SEO plugin for ampless",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/heavymoons/ampless.git",
23
+ "directory": "packages/plugin-seo"
24
+ },
25
+ "homepage": "https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo#readme",
26
+ "bugs": "https://github.com/heavymoons/ampless/issues",
27
+ "dependencies": {
28
+ "ampless": "0.2.0-alpha.0"
29
+ },
30
+ "keywords": [
31
+ "ampless",
32
+ "plugin",
33
+ "seo"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch",
38
+ "lint": "tsc --noEmit",
39
+ "test": "vitest run --passWithNoTests"
40
+ }
41
+ }