diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46284d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Dependencies +node_modules/ + +# Astro build output +dist/ +.astro/ + +# Package tarballs +*.tgz + +# Local env and editor +.env +.env.* +.DS_Store +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index edf65ed..43ffe46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes, newest first. Versions follow semver. ## Unreleased +## 0.5.0 — 2026-10-16 + +- **Fixed: every `npx` bin was a silent no-op.** The CLI guard compared `import.meta.url` to the basename of `process.argv[1]`, but npm runs bins through a `node_modules/.bin/*` symlink, so `argv[1]` was the link (`astro-seo-llms`) and never matched the real file (`llms-txt.mjs`). `npx astro-seo-llms`, `npm run` build hooks and the `github:RelayStack/...` form all exited `0` and wrote nothing. Now resolved with `realpathSync` + `pathToFileURL`, covered by a regression test that executes a bin through a symlink. +- New: `astro-seo-toolkit/integration` — a real Astro integration that writes `llms.txt`, `llms-full.txt`, `sitemap.xml` and `robots.txt` during `astro build`. Exposes `generate()`, `writeFiles()` and `normalizeOptions()` for build scripts. +- New bins: `astro-seo-toolkit` (everything at once) and `astro-seo-check` (validator). +- New: `scripts/sync-skill.mjs` generates the skill's vendored script copies; `npm run check:skill` fails CI if they drift. +- Usage hints now print the `npx astro-seo-*` invocation instead of `node .mjs`. +- Docs: README rewritten — who it's for, Astro integration, comparison table, tighter install paths; social card redesigned and exported to `docs/social-preview.png`. +- Packaging: `engines`, `publishConfig`, optional `astro` peer dependency, expanded keywords, `.gitignore`. +- Tests: 33 (was 27). + ## 0.4.0 — 2026-10-11 - `llms-full.mjs`: spec-companion generator with full bodies inlined + `--limit` guard diff --git a/CITATION.cff b/CITATION.cff index 7579398..3739b8d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,4 +6,5 @@ authors: website: "https://github.com/RelayStack/astro-seo-toolkit" repository-code: "https://github.com/RelayStack/astro-seo-toolkit" license: MIT -version: 0.4.0 +version: 0.5.0 +date-released: "2026-10-16" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1e49e06 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, colour, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behaviour that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologising to those affected by our mistakes, + and learning from the experience +- Focusing on what is best for the community, not just for us as individuals + +Examples of unacceptable behaviour: + +- Sexualised language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Maintainers are responsible for clarifying and enforcing these standards and +will take appropriate and fair corrective action in response to any behaviour +they deem inappropriate, threatening, offensive, or harmful. + +Maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, issues, and other contributions that are not aligned +with this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behaviour may be +reported to the maintainers by opening a report through GitHub. All complaints +will be reviewed and investigated promptly and fairly. Maintainers are obligated +to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Maintainers will follow these Community Impact Guidelines in determining the +consequences for any action they deem in violation of this Code of Conduct: + +**1. Correction** — A private, written warning from maintainers, providing +clarity around the nature of the violation and an explanation of why the +behaviour was inappropriate, possibly including a public apology. + +**2. Warning** — A warning with consequences for continued behaviour, including +no interaction with the people involved for a specified period of time. + +**3. Temporary Ban** — A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. + +**4. Permanent Ban** — A permanent ban from any sort of public interaction +within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1219de..10f93e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,17 +6,18 @@ Questions and ideas go to [Discussions](https://github.com/RelayStack/astro-seo- - Zero dependencies in `seo-toolkit/` and `skills/astro-seo/scripts/`. Node 18+ stdlib only. - Every helper works as CLI and import, with usage errors on bad flags and non-zero exit on failure. -- `skills/astro-seo/scripts/` must stay byte-identical to `seo-toolkit/` (enforced by test). Edit the `seo-toolkit/` copy, then copy over. +- `skills/astro-seo/scripts/` is generated from `seo-toolkit/` by `npm run sync:skill` and must stay byte-identical. Edit the `seo-toolkit/` copy, then run `npm run sync:skill`. `npm test` and `npm run check:skill` both fail on drift. +- Bins are executed through a `node_modules/.bin/` symlink by npm. Detect "am I the CLI?" by comparing resolved paths (`pathToFileURL(realpathSync(process.argv[1]))`), never by matching the file name — a suffix check silently turns the CLI into a no-op under `npx`. - No locale defaults: country/currency explicit, neutral examples (`example.com`, Acme Co.). - No personal data in code, docs, or tests — brand-neutral examples only. ## Flow 1. Fork, branch from `main` (`feat/…`, `fix/…`, `docs/…`). -2. Add tests in `seo-toolkit/test.mjs` (`npm test`, must stay green). +2. Add tests in `seo-toolkit/test.mjs` (`npm test`, must stay green). If you touched `seo-toolkit/`, run `npm run sync:skill` and commit the vendored copies. 3. Open a PR with what changed and how you verified it. 4. One concern per PR; docs updates ride with the feature they describe. ## Releases -Maintainers cut releases from `main` (`vX.Y.Z`, semver) with notes in CHANGELOG.md. +Maintainers bump `package.json` + `CHANGELOG.md` on `main`, then push a `vX.Y.Z` tag. CI (`.github/workflows/release.yml`) checks the tag matches the version and publishes to npm with provenance. diff --git a/README.md b/README.md index 6fc15f7..d856d5c 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,156 @@ -# Astro SEO Toolkit — llms.txt, JSON-LD, IndexNow, Sitemaps +# Astro SEO Toolkit -[![MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Node](https://img.shields.io/badge/node-18%2B-brightgreen.svg)](seo-toolkit/package.json) [![Tests](https://img.shields.io/badge/tests-27%2F27-brightgreen.svg)](seo-toolkit/test.mjs) [![CI](https://github.com/RelayStack/astro-seo-toolkit/actions/workflows/test.yml/badge.svg)](https://github.com/RelayStack/astro-seo-toolkit/actions) +**Generate `llms.txt`, `llms-full.txt`, `sitemap.xml` and `robots.txt`, build JSON-LD schema, and ping IndexNow — from plain Node 18+ with zero dependencies.** -Zero-dependency SEO toolkit for Astro and static sites. Generate `llms.txt`, `sitemap.xml` and `robots.txt`, build JSON-LD schema, and push URL updates via IndexNow — with plain Node 18+, no installs. Built for 2026 search: AI answers (ChatGPT, Perplexity, AI Overviews) cite sites that are machine-readable and quotable, and classic search (Google, Bing) still runs on sitemaps and schema. +[![npm](https://img.shields.io/npm/v/astro-seo-toolkit.svg)](https://www.npmjs.com/package/astro-seo-toolkit) +[![downloads](https://img.shields.io/npm/dm/astro-seo-toolkit.svg)](https://www.npmjs.com/package/astro-seo-toolkit) +[![tests](https://img.shields.io/badge/tests-33%2F33-brightgreen.svg)](seo-toolkit/test.mjs) +[![CI](https://github.com/RelayStack/astro-seo-toolkit/actions/workflows/test.yml/badge.svg)](https://github.com/RelayStack/astro-seo-toolkit/actions) +[![node](https://img.shields.io/badge/node-18%2B-brightgreen.svg)](package.json) +[![license](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -If this saves you an hour, leave a ⭐ — it keeps the project alive. +One command wires it into your build: -- [Tools](#tools) · [Quick start](#quick-start) · [AI agent skill](#use-with-your-ai-agent) · [Wiring into Astro](#wiring-into-astro) · [Locales](#locales) · [Layout](#layout) · [Contributing](#contributing) · [FAQ](#faq) +```console +$ npx astro-seo-toolkit --dir src/content --site https://example.com --out dist +llms.txt, llms-full.txt, sitemap.xml, robots.txt written to dist (12 pages). +``` -## Tools +Built for 2026 search. AI answers (ChatGPT, Perplexity, AI Overviews) cite sites that are **machine-readable and quotable**; classic search (Google, Bing) still runs on **sitemaps and schema**. This covers both halves, without adding a dependency to your lockfile. -| Tool | File | One-liner | -| ---- | ---- | --------- | -| llms.txt | [`seo-toolkit/llms-txt.mjs`](seo-toolkit/llms-txt.mjs) | Content index for LLMs from your Markdown directory | -| llms-full.txt | [`seo-toolkit/llms-full.mjs`](seo-toolkit/llms-full.mjs) | Full page bodies inlined for agents that need more than the index | -| JSON-LD | [`seo-toolkit/json-ld.mjs`](seo-toolkit/json-ld.mjs) | `LocalBusiness`, `Service`, `FAQPage` schema in any locale | -| IndexNow | [`seo-toolkit/indexnow.mjs`](seo-toolkit/indexnow.mjs) | Instant indexing via Bing, Yandex, Seznam, Naver | -| Sitemap | [`seo-toolkit/sitemap.mjs`](seo-toolkit/sitemap.mjs) | `sitemap.xml` from the same content directory | -| robots.txt | [`seo-toolkit/robots.mjs`](seo-toolkit/robots.mjs) | Sitemap reference + optional AI-crawler rules | -| Validator | [`seo-toolkit/check.mjs`](seo-toolkit/check.mjs) | Verify generated files + sitemap/llms agreement before deploy | -| GEO checklist | [`notes/geo-checklist.md`](notes/geo-checklist.md) | 2026 AI-search checklist for local business sites | +> If this saves you an hour, a ⭐ helps the next person find it. -Full flags and API per tool: [`seo-toolkit/README.md`](seo-toolkit/README.md). +- [Quick start](#quick-start) · [Astro integration](#astro-integration) · [Tools](#tools) · [Comparison](#how-it-compares) · [AI agent skill](#use-with-your-ai-agent) · [FAQ](#faq) + +## Who this is for + +- **Astro sites** — drop-in integration, generates every file on `astro build`. +- **Any static site** — Eleventy, Hugo, Next.js static export, a bare Markdown folder. Anything with Markdown and a Node build step. +- **Local business sites** — `LocalBusiness`/`Service`/`FAQPage` schema, one page per service and location. +- **AI agents** — a self-contained `astro-seo` skill your agent can install and run on command. + +You do **not** need this if you only want `` meta tags — that's [`astro-seo`](https://www.npmjs.com/package/astro-seo). This toolkit generates the *files around* the page: the index for LLMs, the sitemap, the robots rules, the schema. ## Quick start +Nothing to install — every file in one command, straight from npm: + ```bash -cd seo-toolkit -npm test # 27/27 should pass -node llms-txt.mjs --dir ../notes --site https://example.com --out ../public/llms.txt -node llms-full.mjs --dir ../notes --site https://example.com --out ../public/llms-full.txt -node sitemap.mjs --dir ../notes --site https://example.com --out ../public/sitemap.xml -node robots.mjs --site https://example.com --out ../public/robots.txt -node check.mjs --dir ../public --site https://example.com -node indexnow.mjs --host example.com --key YOUR_KEY --url https://example.com/new +npx astro-seo-toolkit --dir src/content --site https://example.com --out public ``` -JSON-LD in any page: +No install and pinned to the repo instead of a release: -```js -import { service, toScript } from "./seo-toolkit/json-ld.mjs"; - -const html = toScript(service({ - name: "House Cleaning", - url: "https://example.com/cleaning", - areaServed: "Austin, TX", - price: { from: "120", currency: "USD", note: "From $120, flat rate" }, -})); +```bash +git clone --depth 1 https://github.com/RelayStack/astro-seo-toolkit +node astro-seo-toolkit/seo-toolkit/integration.mjs --dir src/content --site https://example.com --out public ``` -## Use with your AI agent +Note: npm 12 blocks `github:` install specs by default (`allow-git=none`), so `npx github:RelayStack/astro-seo-toolkit` does **not** work out of the box — use the npm release or the clone above. -Install the `astro-seo` skill so your agent implements, audits and launches all of this on command: +Prefer the individual tools? Install once, then the sub-commands are on your PATH: ```bash -npx skills add RelayStack/astro-seo-toolkit --skill astro-seo +npm install -D astro-seo-toolkit + +npx astro-seo-llms --dir src/content --site https://example.com --out public/llms.txt +npx astro-seo-llms-full --dir src/content --site https://example.com --out public/llms-full.txt +npx astro-seo-sitemap --dir src/content --site https://example.com --out public/sitemap.xml +npx astro-seo-robots --site https://example.com --out public/robots.txt +npx astro-seo-check --dir public --site https://example.com +``` + +Validate what you generated before you deploy: + +```console +$ npx astro-seo-check --dir public --site https://example.com +{ + "ok": true, + "issues": [] +} ``` -If your harness doesn't use the registry, copy `skills/astro-seo/` to its skills dir (`~/.claude/skills/`, `~/.codex/skills/`, project's `.opencode/skills/`, …) — the folder is self-contained: SKILL.md, `scripts/`, `references/`. +## Astro integration -No install at all — run straight from GitHub: +Install and add it to `astro.config.mjs`. Every file is regenerated on `astro build`, so your SEO output can never drift from your content. ```bash -npx -y github:RelayStack/astro-seo-toolkit astro-seo-llms --dir src/content --site https://example.com --out public/llms.txt +npm install -D astro-seo-toolkit ``` -Then prompt your agent: `implement astro seo`, `audit this page`, `schema for /pricing`. +```js +// astro.config.mjs +import { defineConfig } from "astro/config"; +import astroSeo from "astro-seo-toolkit/integration"; + +export default defineConfig({ + site: "https://example.com", + integrations: [ + astroSeo({ + contentDir: ["src/content/blog", "src/content/services"], + llmsFull: true, + }), + ], +}); +``` + +| Option | Default | What it does | +| ------ | ------- | ------------ | +| `contentDir` | `src/content` | Markdown dir, or an array of them (deduped by URL) | +| `site` | `config.site` | Base URL; falls back to Astro's `site` | +| `llms` | `true` | Write `llms.txt` | +| `llmsFull` | `false` | Write `llms-full.txt` (full bodies inlined) | +| `llmsFullLimit` | `200` | Max pages inlined into `llms-full.txt` | +| `sitemap` | `true` | Write `sitemap.xml` | +| `robots` | `true` | Write `robots.txt` | +| `disallowAI` | `false` | Block GPTBot/CCBot/ClaudeBot/PerplexityBot in `robots.txt` | +| `changefreq` | `weekly` | `` for every sitemap entry | -## Wiring into Astro +Both files are written into Astro's build output (`dist/`). If you also run [`@astrojs/sitemap`](https://docs.astro.build/en/guides/integrations-guide/sitemap/), turn this one's off with `sitemap: false` — `@astrojs/sitemap` covers every route, this one covers your Markdown. -Run the generators on every build so `llms.txt` and the sitemap never go stale: +The integration is the same code as the CLI, so you can use it outside Astro too: + +```js +import { generate } from "astro-seo-toolkit/integration"; + +const { files, pages } = await generate({ dirs: ["src/content"], site: "https://example.com" }); +``` + +## Tools + +| Tool | File | One-liner | +| ---- | ---- | --------- | +| **Integration** | [`seo-toolkit/integration.mjs`](seo-toolkit/integration.mjs) | Astro integration + `astro-seo-toolkit` CLI for all of the below | +| llms.txt | [`seo-toolkit/llms-txt.mjs`](seo-toolkit/llms-txt.mjs) | Content index for LLMs from your Markdown directory | +| llms-full.txt | [`seo-toolkit/llms-full.mjs`](seo-toolkit/llms-full.mjs) | Full page bodies inlined for agents that need more than the index | +| JSON-LD | [`seo-toolkit/json-ld.mjs`](seo-toolkit/json-ld.mjs) | `LocalBusiness`, `Service`, `FAQPage` schema in any locale | +| IndexNow | [`seo-toolkit/indexnow.mjs`](seo-toolkit/indexnow.mjs) | Instant indexing via Bing, Yandex, Seznam, Naver | +| Sitemap | [`seo-toolkit/sitemap.mjs`](seo-toolkit/sitemap.mjs) | `sitemap.xml` from the same content directory | +| robots.txt | [`seo-toolkit/robots.mjs`](seo-toolkit/robots.mjs) | Sitemap reference + optional AI-crawler rules | +| Validator | [`seo-toolkit/check.mjs`](seo-toolkit/check.mjs) | Verify generated files + sitemap/llms agreement before deploy | +| GEO checklist | [`notes/geo-checklist.md`](notes/geo-checklist.md) | 2026 AI-search checklist for local business sites | + +Full flags and API per tool: [`seo-toolkit/README.md`](seo-toolkit/README.md). + +## How it compares + +| | This toolkit | `astro-seo` | `@astrojs/sitemap` | `llms-txt` generators | +| --- | --- | --- | --- | --- | +| `` meta tags | — | ✅ | — | — | +| `llms.txt` + `llms-full.txt` | ✅ | — | — | ✅ | +| `sitemap.xml` from Markdown | ✅ | — | ✅ (routes) | — | +| `robots.txt` | ✅ | — | — | — | +| JSON-LD builders | ✅ | — | — | — | +| IndexNow submission | ✅ | — | — | — | +| Pre-deploy validator | ✅ | — | — | — | +| Agent skill | ✅ | — | — | — | +| Dependencies | **0** | 0 | several | varies | + +Use them together: `astro-seo` for ``, this for the files. They don't overlap. + +## Wiring into Astro (manual) + +Prefer the integration above. If you'd rather not add a dependency, run the generators on every build: ```json { "scripts": { "build": "node ./seo/llms-txt.mjs --dir src/content --site https://example.com --out public/llms.txt && astro build" } } @@ -85,7 +166,17 @@ const schema = toScript(localBusiness({ name: "Example Co.", url: Astro.site })) ``` -Works with any static setup, not just Astro: Eleventy, Hugo, Next.js static export — anything with a Markdown directory and a Node build step. +## Use with your AI agent + +Install the `astro-seo` skill so your agent implements, audits and launches all of this on command: + +```bash +npx skills add RelayStack/astro-seo-toolkit --skill astro-seo +``` + +If your harness doesn't use the registry, copy `skills/astro-seo/` to its skills dir (`~/.claude/skills/`, `~/.codex/skills/`, project's `.opencode/skills/`, …) — the folder is self-contained: `SKILL.md`, `scripts/`, `references/`. + +Then prompt your agent: `implement astro seo`, `audit this page`, `schema for /pricing`. ## Locales @@ -97,33 +188,47 @@ No locale is assumed. Pass `country` on addresses and `currency` on prices expli seo-toolkit/ dependency-free helpers + tests (Node 18+, no installs) skills/astro-seo/ installable agent skill (self-contained: SKILL.md, scripts/, references/) notes/ short checklists and tooling notes -examples/ minimal runnable Cloudflare Worker snippet +examples/ minimal runnable Astro + Cloudflare Worker snippets +scripts/ repo maintenance (syncs the skill's vendored copies) llms.txt machine-readable index of this repo -package.json npx bins: astro-seo-llms, -llms-full, -sitemap, -robots, -indexnow +package.json bins: astro-seo-toolkit (every file at once) + astro-seo-{llms,llms-full,sitemap,robots,check,indexnow} ``` -## Contributing - -Questions, ideas, bug reports: use [Discussions](../../discussions). PRs welcome — keep helpers dependency-free and tested (`npm test`). - ## FAQ ### Is this an Astro integration? -No. It is framework-agnostic Node scripts plus Astro wiring guides and an example. Anything with a Markdown directory and a Node build step works: Astro, Eleventy, Hugo, Next.js static export. +Yes — since 0.5.0. `astro-seo-toolkit/integration` runs inside `astro build` and writes the files for you. It is *also* just Node scripts, so the same code works with Eleventy, Hugo, Next.js static export, or a bare Markdown folder. ### Does llms.txt improve Google rankings? No. Google ignores `llms.txt`; it serves Claude, GPT, Perplexity and other agents. For Google, prioritize sitemap submission, real indexation, one valid JSON-LD block per page, and quotable content. -### How is this different from astro-seo? +### How is this different from `astro-seo`? `astro-seo` is a meta-tags component for your ``. This toolkit generates the files around it — `llms.txt`, `llms-full.txt`, `sitemap.xml`, `robots.txt` — plus JSON-LD builders, IndexNow submission, a dist validator, and an agent skill that wires it all up. +### Do I need dependencies? + +No. Everything runs on Node 18+ with zero runtime dependencies. `astro` is an optional peer dependency, only used when you use the integration. + ### Can my AI agent install and use this? -Yes. `npx skills add RelayStack/astro-seo-toolkit --skill astro-seo`, or copy `skills/astro-seo/` into any harness skills dir, or run everything via `npx -y github:RelayStack/astro-seo-toolkit ` with no install. +Yes. `npx skills add RelayStack/astro-seo-toolkit --skill astro-seo`, or copy `skills/astro-seo/` into any harness skills dir, or run everything via `npx astro-seo-toolkit` with no install. + +## Contributing + +Questions, ideas, bug reports: use [Discussions](../../discussions). PRs welcome — keep helpers dependency-free and tested. + +```bash +npm test # 33 tests +npm run check:skill # skill scripts must match seo-toolkit/ +``` + +The vendored copies in `skills/astro-seo/scripts/` are generated by `npm run sync:skill`; a test fails if they drift. + +If the toolkit saved you time, a ⭐ is the cheapest way to say thanks. ## License -MIT — see LICENSE. By RelayStack. +MIT — see [LICENSE](LICENSE). By [RelayStack](https://github.com/RelayStack). diff --git a/docs/social-card.svg b/docs/social-card.svg index f934015..8177fce 100644 --- a/docs/social-card.svg +++ b/docs/social-card.svg @@ -1,9 +1,36 @@ - - - - Astro SEO Toolkit - llms.txt · JSON-LD · IndexNow · sitemap · robots.txt - Zero dependencies · Node 18+ · Agent skill included - - MIT Licensed + + + + + + + + + + + + + + + + + + + Astro SEO Toolkit + llms.txt · llms-full.txt · JSON-LD · IndexNow + sitemap.xml · robots.txt · drop-in Astro integration + + + + + + $ + npx astro-seo-generate + ✔ llms.txt sitemap.xml robots.txt llms-full.txt + + + zero dependencies + + github.com/RelayStack/astro-seo-toolkit + MIT · Node 18+ · agent skill included diff --git a/docs/social-preview.png b/docs/social-preview.png new file mode 100644 index 0000000..204487e Binary files /dev/null and b/docs/social-preview.png differ diff --git a/examples/astro/README.md b/examples/astro/README.md index de581cc..8e6cac2 100644 --- a/examples/astro/README.md +++ b/examples/astro/README.md @@ -1,11 +1,25 @@ -# Astro integration example +# Astro example -Minimal wiring of the toolkit into an Astro project. Copy the pattern, not the files — run the generators via `npx` so you always get the latest release: +Minimal Astro project wired up with the integration. Copy the pattern, not the files. ```bash -npx -y github:RelayStack/astro-seo-toolkit astro-seo-llms --dir src/content --site https://example.com --out public/llms.txt -npx -y github:RelayStack/astro-seo-toolkit astro-seo-sitemap --dir src/content --site https://example.com --out public/sitemap.xml -npx -y github:RelayStack/astro-seo-toolkit astro-seo-robots --site https://example.com --out public/robots.txt +npm install +npm run build # astro build also writes llms.txt, llms-full.txt, sitemap.xml, robots.txt into dist/ ``` -See `package.json` (build hook), `astro.config.mjs` (`site` + trailing slash), `src/layouts/Base.astro` (JSON-LD via `set:html`), and `src/content/blog/first-post.md` (frontmatter the generators read). +See: + +- `astro.config.mjs` — the integration, `site`, trailing-slash config +- `package.json` — Astro 7 + `astro-seo-toolkit` as a dev dependency +- `src/layouts/Base.astro` — JSON-LD via `set:html` +- `src/content/blog/first-post.md` — the frontmatter (`title:`, `description:`) the generators read + +## Without the integration + +If you'd rather not add a dependency, run the CLI before `astro build` instead — same output, one extra step: + +```bash +npx astro-seo-toolkit --dir src/content --site https://example.com --out public +``` + +Neither approach needs a config file or a plugin API. The toolkit reads your Markdown directory and writes files; where they land is up to you (`--out`, or Astro's `dist/` via the integration). diff --git a/examples/astro/astro.config.mjs b/examples/astro/astro.config.mjs index d27ea9b..70dac92 100644 --- a/examples/astro/astro.config.mjs +++ b/examples/astro/astro.config.mjs @@ -1,6 +1,14 @@ import { defineConfig } from "astro/config"; +import astroSeo from "astro-seo-toolkit/integration"; export default defineConfig({ site: "https://example.com", trailingSlash: "always", + integrations: [ + // Regenerates llms.txt, llms-full.txt, sitemap.xml and robots.txt on every build. + astroSeo({ + contentDir: "src/content", + llmsFull: true, + }), + ], }); diff --git a/examples/astro/package.json b/examples/astro/package.json index 223d51d..1f20323 100644 --- a/examples/astro/package.json +++ b/examples/astro/package.json @@ -4,8 +4,14 @@ "version": "0.0.0", "type": "module", "scripts": { - "seo": "npx -y github:RelayStack/astro-seo-toolkit astro-seo-llms --dir src/content --site https://example.com --out public/llms.txt && npx -y github:RelayStack/astro-seo-toolkit astro-seo-sitemap --dir src/content --site https://example.com --out public/sitemap.xml", "dev": "astro dev", - "build": "npm run seo && astro build" + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "astro": "^7.0.0" + }, + "devDependencies": { + "astro-seo-toolkit": "^0.5.0" } } diff --git a/llms.txt b/llms.txt index 6d394fb..57622fa 100644 --- a/llms.txt +++ b/llms.txt @@ -1,10 +1,14 @@ # Astro SEO Toolkit -> Zero-dependency SEO helpers for Astro and static sites: llms.txt generator, JSON-LD schema builders, IndexNow submitter, sitemap and robots.txt generators. Tested, no installs, Node 18+. +> Zero-dependency SEO helpers for Astro and static sites: llms.txt generator, JSON-LD schema builders, IndexNow submitter, sitemap and robots.txt generators — plus a drop-in Astro integration and an installable agent skill. Tested, no installs, Node 18+. ## Docs -- [README](https://github.com/RelayStack/astro-seo-toolkit#readme): overview, quick start, Astro wiring -- [Tool reference](https://github.com/RelayStack/astro-seo-toolkit/tree/main/seo-toolkit): flags and API for every helper +- [README](https://github.com/RelayStack/astro-seo-toolkit#readme): overview, quick start, Astro integration +- [Tool reference](https://github.com/RelayStack/astro-seo-toolkit/tree/main/seo-toolkit): flags and API for every helper, including the integration options +- [npm package](https://www.npmjs.com/package/astro-seo-toolkit): install, bins, versions +- [Astro example](https://github.com/RelayStack/astro-seo-toolkit/tree/main/examples/astro): minimal runnable project wired with the integration +- [Agent skill](https://github.com/RelayStack/astro-seo-toolkit/tree/main/skills/astro-seo): installable `astro-seo` skill for Claude, Codex, OpenCode and friends - [GEO checklist](https://github.com/RelayStack/astro-seo-toolkit/blob/main/notes/geo-checklist.md): 2026 AI-search checklist for local business sites +- [Changelog](https://github.com/RelayStack/astro-seo-toolkit/blob/main/CHANGELOG.md): release notes - [Cloudflare Worker example](https://github.com/RelayStack/astro-seo-toolkit/blob/main/examples/cloudflare-worker-hello.js): minimal JSON + health-check worker diff --git a/package.json b/package.json index 4783c0d..930fcfc 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,67 @@ { "name": "astro-seo-toolkit", - "version": "0.4.0", - "description": "Zero-dependency SEO toolkit for Astro & static sites: llms.txt, JSON-LD, IndexNow, sitemap.xml, robots.txt — plus an installable agent skill.", + "version": "0.5.0", + "description": "Zero-dependency SEO toolkit for Astro & static sites: llms.txt, JSON-LD, IndexNow, sitemap.xml, robots.txt — plus a drop-in Astro integration and an installable agent skill.", "type": "module", "license": "MIT", + "author": "RelayStack", + "engines": { + "node": ">=18" + }, "bin": { + "astro-seo-toolkit": "./seo-toolkit/integration.mjs", + "astro-seo-generate": "./seo-toolkit/integration.mjs", + "astro-seo-check": "./seo-toolkit/check.mjs", "astro-seo-llms": "./seo-toolkit/llms-txt.mjs", "astro-seo-llms-full": "./seo-toolkit/llms-full.mjs", "astro-seo-sitemap": "./seo-toolkit/sitemap.mjs", "astro-seo-robots": "./seo-toolkit/robots.mjs", "astro-seo-indexnow": "./seo-toolkit/indexnow.mjs" }, - "keywords": ["seo", "astro", "json-ld", "llms-txt", "indexnow", "sitemap", "geo"], + "keywords": [ + "seo", + "astro", + "astro-integration", + "json-ld", + "llms-txt", + "indexnow", + "sitemap", + "robots-txt", + "structured-data", + "geo", + "ai-search", + "static-site" + ], "homepage": "https://github.com/RelayStack/astro-seo-toolkit#readme", - "files": ["seo-toolkit/", "skills/", "examples/", "notes/", "README.md", "LICENSE", "CHANGELOG.md"], + "bugs": { + "url": "https://github.com/RelayStack/astro-seo-toolkit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RelayStack/astro-seo-toolkit.git" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "astro": ">=4" + }, + "peerDependenciesMeta": { + "astro": { + "optional": true + } + }, + "files": [ + "seo-toolkit/", + "skills/", + "examples/", + "notes/", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], "exports": { + "./integration": "./seo-toolkit/integration.mjs", "./llms-txt": "./seo-toolkit/llms-txt.mjs", "./llms-full": "./seo-toolkit/llms-full.mjs", "./json-ld": "./seo-toolkit/json-ld.mjs", @@ -24,11 +71,11 @@ "./check": "./seo-toolkit/check.mjs", "./package.json": "./package.json" }, - "repository": { - "type": "git", - "url": "https://github.com/RelayStack/astro-seo-toolkit.git" - }, "scripts": { - "test": "node --test seo-toolkit/test.mjs" + "test": "node --test seo-toolkit/test.mjs", + "sync:skill": "node scripts/sync-skill.mjs", + "check:skill": "node scripts/sync-skill.mjs --check", + "verify": "npm test && npm run check:skill", + "prepack": "npm run sync:skill" } } diff --git a/scripts/sync-skill.mjs b/scripts/sync-skill.mjs new file mode 100644 index 0000000..be03b28 --- /dev/null +++ b/scripts/sync-skill.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Copies the canonical helpers in `seo-toolkit/` into the self-contained agent + * skill at `skills/astro-seo/scripts/`. The skill ships as a flat folder users + * copy into ~/.claude/skills/ or a project's .opencode/skills/, so it cannot + * import across directories — the duplication is intentional and generated. + * + * node scripts/sync-skill.mjs # update the vendored copies + * node scripts/sync-skill.mjs --check # fail if they drifted (CI) + */ +import { readdir, readFile, writeFile, mkdir, chmod } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE = join(root, "seo-toolkit"); +const TARGET = join(root, "skills/astro-seo/scripts"); +const SKIP = new Set(["test.mjs"]); + +export async function syncSkill({ check = false } = {}) { + const files = (await readdir(SOURCE)).filter((f) => f.endsWith(".mjs") && !SKIP.has(f)).sort(); + const drift = []; + + if (!check) await mkdir(TARGET, { recursive: true }); + const vendored = new Set(await readdir(TARGET).catch(() => [])); + + for (const name of files) { + const source = await readFile(join(SOURCE, name), "utf8"); + const current = vendored.has(name) ? await readFile(join(TARGET, name), "utf8") : null; + if (check) { + if (current !== source) drift.push(name); + continue; + } + await writeFile(join(TARGET, name), source); + await chmod(join(TARGET, name), 0o755); + } + + if (check) { + const stale = [...vendored].filter((f) => f.endsWith(".mjs") && !files.includes(f)); + return { files, drift: [...drift, ...stale] }; + } + return { files, drift: [] }; +} + +const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); + +if (isCli) { + const check = process.argv.includes("--check"); + const { files, drift } = await syncSkill({ check }); + if (check && drift.length > 0) { + console.error(`skill scripts out of sync: ${drift.join(", ")}\nRun: npm run sync:skill`); + process.exit(1); + } + console.error(check ? `skill scripts in sync (${files.length} files).` : `synced ${files.length} scripts to skills/astro-seo/scripts/.`); +} diff --git a/seo-toolkit/README.md b/seo-toolkit/README.md index 539f96a..295e27f 100644 --- a/seo-toolkit/README.md +++ b/seo-toolkit/README.md @@ -2,6 +2,47 @@ All helpers live in `seo-toolkit/`, run on Node 18+ with zero dependencies. Every file works as a CLI and as an import. Exit code is non-zero on error; `--out FILE` writes, otherwise output goes to stdout. +Usage hints print the `npx astro-seo-*` form, which is how most people run these. From this repo the equivalents are `node .mjs …`. + +## integration.mjs — Astro integration + one-shot generator + +Writes `llms.txt`, `llms-full.txt`, `sitemap.xml` and `robots.txt` in a single pass. As an Astro integration it runs on `astro build`: + +```js +// astro.config.mjs +import astroSeo from "astro-seo-toolkit/integration"; + +export default defineConfig({ + site: "https://example.com", + integrations: [astroSeo({ contentDir: ["src/content/blog", "src/content/services"], llmsFull: true })], +}); +``` + +| Option | Default | What it does | +| ------ | ------- | ------------ | +| `contentDir` | `src/content` | Markdown dir, or an array of them (deduped by URL) | +| `site` | `config.site` | Base URL; falls back to Astro's `site` | +| `llms` / `llmsFull` | `true` / `false` | Write `llms.txt` / `llms-full.txt` | +| `llmsFullLimit` | `200` | Max pages inlined into `llms-full.txt` | +| `sitemap` / `robots` | `true` / `true` | Write `sitemap.xml` / `robots.txt` | +| `disallowAI` | `false` | Block GPTBot/CCBot/ClaudeBot/PerplexityBot | +| `changefreq` | `weekly` | `` for every sitemap entry | + +The same code runs standalone, outside Astro: + +```bash +node integration.mjs --dir src/content --dir src/pages --site https://example.com --out dist --llms-full +``` + +```js +import { generate, writeFiles } from "./integration.mjs"; + +const { files, pages } = await generate({ dirs: ["src/content"], site: "https://example.com" }); +await writeFiles("dist", files); +``` + +`normalizeOptions()` is exported too, for build scripts that need the defaults. + ## llms-txt.mjs — content index for LLMs ```bash @@ -127,5 +168,5 @@ Reads `llms.txt`, `sitemap.xml` and `robots.txt` from the dir, checks each again ## Test ```bash -npm test # node --test, 27 tests, no framework +npm test # node --test, 33 tests, no framework ``` diff --git a/seo-toolkit/check.mjs b/seo-toolkit/check.mjs index 59fc8b0..4f3d214 100755 --- a/seo-toolkit/check.mjs +++ b/seo-toolkit/check.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; export function checkLlmsTxt(text) { const issues = []; @@ -61,12 +63,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir) { - console.error("Usage: node check.mjs --dir public [--site https://example.com]"); + console.error("Usage: npx astro-seo-check --dir public [--site https://example.com]"); process.exit(1); } const issues = []; diff --git a/seo-toolkit/indexnow.mjs b/seo-toolkit/indexnow.mjs index 1a7b5ee..acb750d 100755 --- a/seo-toolkit/indexnow.mjs +++ b/seo-toolkit/indexnow.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + export function buildPayload({ host, key, urls }) { return { endpoint: "https://api.indexnow.org/indexnow", @@ -31,12 +34,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.host || !a.key || a.urls.length === 0) { - console.error("Usage: node indexnow.mjs --host example.com --key KEY --url https://example.com/a [--url ..]"); + console.error("Usage: npx astro-seo-indexnow --host example.com --key KEY --url https://example.com/a [--url ..]"); process.exit(1); } const result = await submit({ host: a.host, key: a.key, urls: a.urls }); diff --git a/seo-toolkit/integration.mjs b/seo-toolkit/integration.mjs new file mode 100755 index 0000000..2ffce38 --- /dev/null +++ b/seo-toolkit/integration.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Astro integration: generate llms.txt, llms-full.txt, sitemap.xml and + * robots.txt into the build output, so they can never go stale. + * + * // astro.config.mjs + * import astroSeo from "astro-seo-toolkit/integration"; + * + * export default defineConfig({ + * site: "https://example.com", + * integrations: [astroSeo({ contentDir: "src/content" })], + * }); + * + * Also runs as a CLI, so the same code covers non-Astro builds: + * + * node integration.mjs --dir src/content --site https://example.com --out dist + */ +import { realpathSync } from "node:fs"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { collectFull, renderLlmsFull } from "./llms-full.mjs"; +import { collectPages, renderLlmsTxt } from "./llms-txt.mjs"; +import { renderRobots } from "./robots.mjs"; +import { renderSitemap } from "./sitemap.mjs"; + +const DEFAULTS = { + contentDir: "src/content", + llms: true, + llmsFull: false, + sitemap: true, + robots: true, + disallowAI: false, + changefreq: "weekly", + llmsFullLimit: 200, +}; + +/** Accepts `dirs`, plus the `contentDir` (string or array) and `contentDirs` aliases. */ +export function normalizeOptions(options = {}) { + const raw = options.dirs ?? options.contentDirs ?? options.contentDir ?? DEFAULTS.contentDir; + return { ...DEFAULTS, ...options, dirs: Array.isArray(raw) ? raw : [raw] }; +} + +/** Builds every requested file in memory. Returns `{ files, pages }`. */ +export async function generate(options = {}) { + const { + dirs, + site, + title, + description, + llms, + llmsFull, + sitemap: wantSitemap, + robots: wantRobots, + disallowAI, + changefreq, + llmsFullLimit, + cwd = process.cwd(), + } = normalizeOptions(options); + + const base = String(site || "").replace(/\/$/, ""); + const files = {}; + + const merge = (pages) => { + const seen = new Map(); + for (const page of pages) if (!seen.has(page.url)) seen.set(page.url, page); + return [...seen.values()].sort((a, b) => a.url.localeCompare(b.url)); + }; + + const collected = []; + for (const dir of dirs) collected.push(...(await collectPages(resolve(cwd, dir), base))); + const pages = merge(collected); + + const meta = { + title: title || base || "Content index", + description: description || `Content index for ${base}`, + }; + + if (llms) files["llms.txt"] = renderLlmsTxt({ ...meta, pages }); + if (llmsFull) { + const full = []; + for (const dir of dirs) full.push(...(await collectFull(resolve(cwd, dir), base, llmsFullLimit))); + files["llms-full.txt"] = renderLlmsFull({ ...meta, pages: merge(full) }); + } + if (wantSitemap) files["sitemap.xml"] = renderSitemap(pages, { changefreq }); + if (wantRobots) files["robots.txt"] = renderRobots({ sitemap: `${base}/sitemap.xml`, disallowAI }); + + return { files, pages }; +} + +/** Writes a `{ name: contents }` map into `outDir`. Returns the names written. */ +export async function writeFiles(outDir, files) { + await mkdir(outDir, { recursive: true }); + const written = []; + for (const [name, content] of Object.entries(files)) { + await writeFile(join(outDir, name), content); + written.push(name); + } + return written; +} + +export default function astroSeo(options = {}) { + let site = options.site; + + const resolveSite = () => String(site || "").replace(/\/$/, ""); + + return { + name: "astro-seo-toolkit", + hooks: { + "astro:config:setup": ({ config, logger }) => { + if (!site && config.site) site = config.site.toString(); + if (!resolveSite()) { + logger.warn( + "no `site` configured — llms.txt and sitemap.xml need absolute URLs. Set `site:` in astro.config.mjs or pass `site` to astroSeo().", + ); + } + }, + "astro:build:done": async ({ dir, logger }) => { + const base = resolveSite(); + if (!base) { + logger.error("skipping generation: no `site` configured."); + return; + } + const { files, pages } = await generate({ ...options, site: base }); + const written = await writeFiles(fileURLToPath(dir), files); + logger.info(`wrote ${written.join(", ")} (${pages.length} pages)`); + }, + }, + }; +} + +function parseArgs(argv) { + const out = { dir: [], llms: true, sitemap: true, robots: true, llmsFull: false, disallowAI: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--dir") out.dir.push(argv[++i]); + else if (arg === "--llms-full") out.llmsFull = true; + else if (arg === "--disallow-ai") out.disallowAI = true; + else if (arg === "--no-llms") out.llms = false; + else if (arg === "--no-sitemap") out.sitemap = false; + else if (arg === "--no-robots") out.robots = false; + else if (arg.startsWith("--")) { + const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true"; + out[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value; + } + } + return out; +} + +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); + +if (isCli) { + const a = parseArgs(process.argv.slice(2)); + if (a.dir.length === 0 || !a.site) { + console.error( + "Usage: npx astro-seo-toolkit --dir src/content [--dir src/pages] --site https://example.com [--out dist] [--llms-full] [--no-robots] [--disallow-ai]", + ); + process.exit(1); + } + const { files, pages } = await generate({ ...a, contentDirs: a.dir }); + const names = Object.keys(files); + if (a.out) { + await writeFiles(a.out, files); + console.error(`${names.join(", ")} written to ${a.out} (${pages.length} pages).`); + } else { + for (const name of names) process.stdout.write(`\n${files[name]}`); + } +} diff --git a/seo-toolkit/llms-full.mjs b/seo-toolkit/llms-full.mjs index 9e51332..3274468 100755 --- a/seo-toolkit/llms-full.mjs +++ b/seo-toolkit/llms-full.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; +import { pathToFileURL } from "node:url"; import { parseFrontmatter } from "./llms-txt.mjs"; export function stripFrontmatter(text) { @@ -49,12 +51,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir || !a.site) { - console.error("Usage: node llms-full.mjs --dir src/content/blog --site https://example.com [--out public/llms-full.txt] [--limit 200]"); + console.error("Usage: npx astro-seo-llms-full --dir src/content/blog --site https://example.com [--out public/llms-full.txt] [--limit 200]"); process.exit(1); } const pages = await collectFull(a.dir, a.site, parseInt(a.limit || "200", 10)); diff --git a/seo-toolkit/llms-txt.mjs b/seo-toolkit/llms-txt.mjs index e3f5a8f..45e8970 100755 --- a/seo-toolkit/llms-txt.mjs +++ b/seo-toolkit/llms-txt.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; +import { pathToFileURL } from "node:url"; export function parseFrontmatter(text) { text = text.replace(/\r\n/g, "\n"); @@ -64,12 +66,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir || !a.site) { - console.error('Usage: node llms-txt.mjs --dir src/content/blog --site https://example.com [--title ..] [--description ..] [--out llms.txt]'); + console.error('Usage: npx astro-seo-llms --dir src/content/blog --site https://example.com [--title ..] [--description ..] [--out llms.txt]'); process.exit(1); } const pages = await collectPages(a.dir, a.site); diff --git a/seo-toolkit/package.json b/seo-toolkit/package.json index 88c61f1..3c82fba 100644 --- a/seo-toolkit/package.json +++ b/seo-toolkit/package.json @@ -1,10 +1,21 @@ { "name": "astro-seo-toolkit", - "version": "0.4.0", + "version": "0.5.0", "description": "Zero-dependency SEO toolkit for Astro & static sites: llms.txt, JSON-LD, IndexNow, sitemap.xml, robots.txt.", "type": "module", "license": "MIT", - "keywords": ["seo", "astro", "json-ld", "llms-txt", "indexnow", "sitemap", "geo"], + "engines": { + "node": ">=18" + }, + "keywords": [ + "seo", + "astro", + "json-ld", + "llms-txt", + "indexnow", + "sitemap", + "geo" + ], "scripts": { "test": "node --test test.mjs" } diff --git a/seo-toolkit/robots.mjs b/seo-toolkit/robots.mjs index caf0fbd..d4161e8 100755 --- a/seo-toolkit/robots.mjs +++ b/seo-toolkit/robots.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { writeFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; export function renderRobots({ sitemap = "/sitemap.xml", disallowAI = false } = {}) { const lines = ["User-agent: *", "Allow: /", ""]; @@ -22,12 +24,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.site) { - console.error("Usage: node robots.mjs --site https://example.com [--sitemap /sitemap.xml] [--disallow-ai] [--out public/robots.txt]"); + console.error("Usage: npx astro-seo-robots --site https://example.com [--sitemap /sitemap.xml] [--disallow-ai] [--out public/robots.txt]"); process.exit(1); } const sitemap = (a.sitemap || "/sitemap.xml").startsWith("http") diff --git a/seo-toolkit/sitemap.mjs b/seo-toolkit/sitemap.mjs index 9650fbf..ad248d0 100755 --- a/seo-toolkit/sitemap.mjs +++ b/seo-toolkit/sitemap.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { writeFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; import { collectPages } from "./llms-txt.mjs"; export function renderSitemap(pages, { changefreq = "weekly" } = {}) { @@ -19,12 +21,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir || !a.site) { - console.error("Usage: node sitemap.mjs --dir src/content/blog --site https://example.com [--out public/sitemap.xml]"); + console.error("Usage: npx astro-seo-sitemap --dir src/content/blog --site https://example.com [--out public/sitemap.xml]"); process.exit(1); } const pages = await collectPages(a.dir, a.site); diff --git a/seo-toolkit/test.mjs b/seo-toolkit/test.mjs index d42adc8..0d7d3b4 100755 --- a/seo-toolkit/test.mjs +++ b/seo-toolkit/test.mjs @@ -194,3 +194,104 @@ test("collectPages skips hidden dirs and node_modules", async () => { await rm(dir, { recursive: true, force: true }); assert.deepEqual(pages.map((p) => p.url), ["https://example.com/a"]); }); + +test("normalizeOptions accepts string, array and the contentDirs alias", async () => { + const { normalizeOptions } = await import("./integration.mjs"); + assert.deepEqual(normalizeOptions({ contentDir: "src/content" }).dirs, ["src/content"]); + assert.deepEqual(normalizeOptions({ contentDir: ["a", "b"] }).dirs, ["a", "b"]); + assert.deepEqual(normalizeOptions({ contentDirs: ["c"] }).dirs, ["c"]); + assert.deepEqual(normalizeOptions().dirs, ["src/content"]); +}); + +test("default export is an Astro integration with the expected hooks", async () => { + const { default: astroSeo } = await import("./integration.mjs"); + const integration = astroSeo({ contentDir: "src/content" }); + assert.equal(integration.name, "astro-seo-toolkit"); + assert.equal(typeof integration.hooks["astro:config:setup"], "function"); + assert.equal(typeof integration.hooks["astro:build:done"], "function"); +}); + +test("generate emits llms/sitemap/robots and dedupes across dirs", async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join: pjoin } = await import("node:path"); + const { generate } = await import("./integration.mjs"); + const dir = await mkdtemp(pjoin(tmpdir(), "seo-gen-")); + await mkdir(pjoin(dir, "blog"), { recursive: true }); + await writeFile(pjoin(dir, "blog", "a.md"), "---\ntitle: A\ndescription: da\n---\n# A"); + await writeFile(pjoin(dir, "b.md"), "---\ntitle: B\n---\n# B"); + const { files, pages } = await generate({ dirs: [dir, dir], site: "https://example.com" }); + await rm(dir, { recursive: true, force: true }); + assert.deepEqual(pages.map((p) => p.url), ["https://example.com/b", "https://example.com/blog/a"]); + assert.ok(files["llms.txt"].includes("- [A](https://example.com/blog/a): da")); + assert.ok(files["sitemap.xml"].includes("https://example.com/b")); + assert.ok(files["robots.txt"].includes("Sitemap: https://example.com/sitemap.xml")); + assert.equal(files["llms-full.txt"], undefined); +}); + +test("generate honours llmsFull, robots and trailing-slash site", async () => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join: pjoin } = await import("node:path"); + const { generate } = await import("./integration.mjs"); + const dir = await mkdtemp(pjoin(tmpdir(), "seo-flags-")); + await writeFile(pjoin(dir, "a.md"), "---\ntitle: A\n---\n# Body A"); + const { files } = await generate({ + dirs: [dir], + site: "https://example.com/", + llmsFull: true, + robots: false, + disallowAI: true, + }); + await rm(dir, { recursive: true, force: true }); + assert.ok(files["llms-full.txt"].includes("## A")); + assert.ok(files["llms-full.txt"].includes("# Body A")); + assert.equal(files["robots.txt"], undefined); + assert.ok(files["sitemap.xml"].includes("https://example.com/a")); +}); + +test("writeFiles writes each file into the output directory", async () => { + const { mkdtemp, readFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join: pjoin } = await import("node:path"); + const { writeFiles } = await import("./integration.mjs"); + const dir = await mkdtemp(pjoin(tmpdir(), "seo-out-")); + const written = await writeFiles(pjoin(dir, "dist"), { "llms.txt": "hello", "robots.txt": "there" }); + const text = await readFile(pjoin(dir, "dist", "llms.txt"), "utf8"); + await rm(dir, { recursive: true, force: true }); + assert.deepEqual(written, ["llms.txt", "robots.txt"]); + assert.equal(text, "hello"); +}); + +test("bins run when invoked through a .bin-style symlink", { skip: process.platform === "win32" }, async () => { + const { mkdtemp, mkdir, writeFile, symlink, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join: pjoin, dirname: pdirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const run = promisify(execFile); + + const root = pjoin(pdirname(fileURLToPath(import.meta.url)), ".."); + const tmp = await mkdtemp(pjoin(tmpdir(), "seo-bin-")); + await mkdir(pjoin(tmp, "content"), { recursive: true }); + await writeFile(pjoin(tmp, "content", "a.md"), "---\ntitle: A\ndescription: da\n---\n# A"); + + // npm installs bins as symlinks (node_modules/.bin/*), and npx/npm run execute + // them that way. argv[1] is then the link while import.meta.url is the real + // file, so a naive suffix comparison silently disables the CLI. + const cases = [ + ["astro-seo-sitemap", "sitemap.mjs", ["--dir", "content", "--site", "https://example.com"]], + ["astro-seo-toolkit", "integration.mjs", ["--dir", "content", "--site", "https://example.com"]], + ]; + const out = {}; + for (const [bin, file, argv] of cases) { + const link = pjoin(tmp, bin); + await symlink(pjoin(root, "seo-toolkit", file), link); + out[bin] = (await run(link, argv, { cwd: tmp })).stdout; + } + await rm(tmp, { recursive: true, force: true }); + + assert.ok(out["astro-seo-sitemap"].includes(".mjs --help`-style usage errors) and as imports. +Zero-dependency Node scripts in `scripts/` plus Astro wiring guides in `references/`. Node 18+, no installs. Scripts run standalone (`node scripts/.mjs`; usage errors list the flags) and as imports. `scripts/integration.mjs` writes every file in one pass. ## Commands @@ -18,6 +18,7 @@ Zero-dependency Node scripts in `scripts/` plus Astro wiring guides in `referenc | implement | Full wiring into an Astro project (section below) | | audit [path] | Run every check in `references/audit.md` against source or `dist/`, report pass/fail | | schema [page] | Emit JSON-LD with `scripts/json-ld.mjs`; per-page recipes in `references/astro-wiring.md` | +| generate | Write llms.txt + sitemap.xml + robots.txt in one pass with `scripts/integration.mjs` | | llms-txt | Generate `public/llms.txt` from the content dir, then validate it | | llms-full | Inline full page bodies into `public/llms-full.txt` (companion for agents that need more than the index) | | sitemap / robots | Generate `public/sitemap.xml` / `public/robots.txt` | @@ -29,14 +30,16 @@ Default to implement when the request is vague ("seo this site", "launch checkli ## Implement 1. Locate the content dir (`src/content/**`, else `src/pages/**`) and the canonical URL (`astro.config.mjs` → `site`). Ask for the URL if `site` is unset. -2. From the skill dir, generate into the Astro project's `public/`: - `node scripts/llms-txt.mjs --dir --site --out /public/llms.txt` - `node scripts/sitemap.mjs --dir --site --out /public/sitemap.xml` - `node scripts/robots.mjs --site --out /public/robots.txt` -3. Schema: one `toScript(...)` block per page type in the Astro layout via `set:html` (recipes in `references/astro-wiring.md`). Every public page gets exactly one valid JSON-LD block. -4. Build hook: prepend generation to the project's `build` script so the files never go stale. -5. Verify with the audit command. Fix failures, never hand-wave them. -6. Post-deploy: submit changed URLs with `scripts/indexnow.mjs` (needs the site's IndexNow key file in place). +2. Best option when the project can take a dev dependency: add the integration to `astro.config.mjs` so the files regenerate on every build. + `npm install -D astro-seo-toolkit` + `import astroSeo from "astro-seo-toolkit/integration";` → `integrations: [astroSeo({ contentDir: })]` +3. Otherwise generate into the project's `public/` with a single command: + `node scripts/integration.mjs --dir --site --out /public` + (or one file at a time: `node scripts/llms-txt.mjs`, `scripts/sitemap.mjs`, `scripts/robots.mjs`) +4. Schema: one `toScript(...)` block per page type in the Astro layout via `set:html` (recipes in `references/astro-wiring.md`). Every public page gets exactly one valid JSON-LD block. +5. Build hook: prepend generation to the project's `build` script so the files never go stale. +6. Verify with the audit command. Fix failures, never hand-wave them. +7. Post-deploy: submit changed URLs with `scripts/indexnow.mjs` (needs the site's IndexNow key file in place). ## Ground rules diff --git a/skills/astro-seo/references/astro-wiring.md b/skills/astro-seo/references/astro-wiring.md index 440bca4..d43b1e0 100644 --- a/skills/astro-seo/references/astro-wiring.md +++ b/skills/astro-seo/references/astro-wiring.md @@ -49,7 +49,7 @@ Recipes: } } ``` -Copy the five scripts into the Astro project's `./seo/` (or run via `npx -y github:RelayStack/astro-seo-toolkit `). +Copy the five scripts into the Astro project's `./seo/`, or install the package and use the integration (`astro-seo-toolkit/integration`), or run the CLI: `npx astro-seo-toolkit --dir src/content --site https://example.com --out public`. ## Dynamic routes diff --git a/skills/astro-seo/scripts/check.mjs b/skills/astro-seo/scripts/check.mjs index 59fc8b0..4f3d214 100755 --- a/skills/astro-seo/scripts/check.mjs +++ b/skills/astro-seo/scripts/check.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; export function checkLlmsTxt(text) { const issues = []; @@ -61,12 +63,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir) { - console.error("Usage: node check.mjs --dir public [--site https://example.com]"); + console.error("Usage: npx astro-seo-check --dir public [--site https://example.com]"); process.exit(1); } const issues = []; diff --git a/skills/astro-seo/scripts/indexnow.mjs b/skills/astro-seo/scripts/indexnow.mjs index 1a7b5ee..acb750d 100755 --- a/skills/astro-seo/scripts/indexnow.mjs +++ b/skills/astro-seo/scripts/indexnow.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + export function buildPayload({ host, key, urls }) { return { endpoint: "https://api.indexnow.org/indexnow", @@ -31,12 +34,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.host || !a.key || a.urls.length === 0) { - console.error("Usage: node indexnow.mjs --host example.com --key KEY --url https://example.com/a [--url ..]"); + console.error("Usage: npx astro-seo-indexnow --host example.com --key KEY --url https://example.com/a [--url ..]"); process.exit(1); } const result = await submit({ host: a.host, key: a.key, urls: a.urls }); diff --git a/skills/astro-seo/scripts/integration.mjs b/skills/astro-seo/scripts/integration.mjs new file mode 100755 index 0000000..2ffce38 --- /dev/null +++ b/skills/astro-seo/scripts/integration.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Astro integration: generate llms.txt, llms-full.txt, sitemap.xml and + * robots.txt into the build output, so they can never go stale. + * + * // astro.config.mjs + * import astroSeo from "astro-seo-toolkit/integration"; + * + * export default defineConfig({ + * site: "https://example.com", + * integrations: [astroSeo({ contentDir: "src/content" })], + * }); + * + * Also runs as a CLI, so the same code covers non-Astro builds: + * + * node integration.mjs --dir src/content --site https://example.com --out dist + */ +import { realpathSync } from "node:fs"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { collectFull, renderLlmsFull } from "./llms-full.mjs"; +import { collectPages, renderLlmsTxt } from "./llms-txt.mjs"; +import { renderRobots } from "./robots.mjs"; +import { renderSitemap } from "./sitemap.mjs"; + +const DEFAULTS = { + contentDir: "src/content", + llms: true, + llmsFull: false, + sitemap: true, + robots: true, + disallowAI: false, + changefreq: "weekly", + llmsFullLimit: 200, +}; + +/** Accepts `dirs`, plus the `contentDir` (string or array) and `contentDirs` aliases. */ +export function normalizeOptions(options = {}) { + const raw = options.dirs ?? options.contentDirs ?? options.contentDir ?? DEFAULTS.contentDir; + return { ...DEFAULTS, ...options, dirs: Array.isArray(raw) ? raw : [raw] }; +} + +/** Builds every requested file in memory. Returns `{ files, pages }`. */ +export async function generate(options = {}) { + const { + dirs, + site, + title, + description, + llms, + llmsFull, + sitemap: wantSitemap, + robots: wantRobots, + disallowAI, + changefreq, + llmsFullLimit, + cwd = process.cwd(), + } = normalizeOptions(options); + + const base = String(site || "").replace(/\/$/, ""); + const files = {}; + + const merge = (pages) => { + const seen = new Map(); + for (const page of pages) if (!seen.has(page.url)) seen.set(page.url, page); + return [...seen.values()].sort((a, b) => a.url.localeCompare(b.url)); + }; + + const collected = []; + for (const dir of dirs) collected.push(...(await collectPages(resolve(cwd, dir), base))); + const pages = merge(collected); + + const meta = { + title: title || base || "Content index", + description: description || `Content index for ${base}`, + }; + + if (llms) files["llms.txt"] = renderLlmsTxt({ ...meta, pages }); + if (llmsFull) { + const full = []; + for (const dir of dirs) full.push(...(await collectFull(resolve(cwd, dir), base, llmsFullLimit))); + files["llms-full.txt"] = renderLlmsFull({ ...meta, pages: merge(full) }); + } + if (wantSitemap) files["sitemap.xml"] = renderSitemap(pages, { changefreq }); + if (wantRobots) files["robots.txt"] = renderRobots({ sitemap: `${base}/sitemap.xml`, disallowAI }); + + return { files, pages }; +} + +/** Writes a `{ name: contents }` map into `outDir`. Returns the names written. */ +export async function writeFiles(outDir, files) { + await mkdir(outDir, { recursive: true }); + const written = []; + for (const [name, content] of Object.entries(files)) { + await writeFile(join(outDir, name), content); + written.push(name); + } + return written; +} + +export default function astroSeo(options = {}) { + let site = options.site; + + const resolveSite = () => String(site || "").replace(/\/$/, ""); + + return { + name: "astro-seo-toolkit", + hooks: { + "astro:config:setup": ({ config, logger }) => { + if (!site && config.site) site = config.site.toString(); + if (!resolveSite()) { + logger.warn( + "no `site` configured — llms.txt and sitemap.xml need absolute URLs. Set `site:` in astro.config.mjs or pass `site` to astroSeo().", + ); + } + }, + "astro:build:done": async ({ dir, logger }) => { + const base = resolveSite(); + if (!base) { + logger.error("skipping generation: no `site` configured."); + return; + } + const { files, pages } = await generate({ ...options, site: base }); + const written = await writeFiles(fileURLToPath(dir), files); + logger.info(`wrote ${written.join(", ")} (${pages.length} pages)`); + }, + }, + }; +} + +function parseArgs(argv) { + const out = { dir: [], llms: true, sitemap: true, robots: true, llmsFull: false, disallowAI: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--dir") out.dir.push(argv[++i]); + else if (arg === "--llms-full") out.llmsFull = true; + else if (arg === "--disallow-ai") out.disallowAI = true; + else if (arg === "--no-llms") out.llms = false; + else if (arg === "--no-sitemap") out.sitemap = false; + else if (arg === "--no-robots") out.robots = false; + else if (arg.startsWith("--")) { + const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true"; + out[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value; + } + } + return out; +} + +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); + +if (isCli) { + const a = parseArgs(process.argv.slice(2)); + if (a.dir.length === 0 || !a.site) { + console.error( + "Usage: npx astro-seo-toolkit --dir src/content [--dir src/pages] --site https://example.com [--out dist] [--llms-full] [--no-robots] [--disallow-ai]", + ); + process.exit(1); + } + const { files, pages } = await generate({ ...a, contentDirs: a.dir }); + const names = Object.keys(files); + if (a.out) { + await writeFiles(a.out, files); + console.error(`${names.join(", ")} written to ${a.out} (${pages.length} pages).`); + } else { + for (const name of names) process.stdout.write(`\n${files[name]}`); + } +} diff --git a/skills/astro-seo/scripts/llms-full.mjs b/skills/astro-seo/scripts/llms-full.mjs index 9e51332..3274468 100755 --- a/skills/astro-seo/scripts/llms-full.mjs +++ b/skills/astro-seo/scripts/llms-full.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; +import { pathToFileURL } from "node:url"; import { parseFrontmatter } from "./llms-txt.mjs"; export function stripFrontmatter(text) { @@ -49,12 +51,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir || !a.site) { - console.error("Usage: node llms-full.mjs --dir src/content/blog --site https://example.com [--out public/llms-full.txt] [--limit 200]"); + console.error("Usage: npx astro-seo-llms-full --dir src/content/blog --site https://example.com [--out public/llms-full.txt] [--limit 200]"); process.exit(1); } const pages = await collectFull(a.dir, a.site, parseInt(a.limit || "200", 10)); diff --git a/skills/astro-seo/scripts/llms-txt.mjs b/skills/astro-seo/scripts/llms-txt.mjs index e3f5a8f..45e8970 100755 --- a/skills/astro-seo/scripts/llms-txt.mjs +++ b/skills/astro-seo/scripts/llms-txt.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; +import { pathToFileURL } from "node:url"; export function parseFrontmatter(text) { text = text.replace(/\r\n/g, "\n"); @@ -64,12 +66,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir || !a.site) { - console.error('Usage: node llms-txt.mjs --dir src/content/blog --site https://example.com [--title ..] [--description ..] [--out llms.txt]'); + console.error('Usage: npx astro-seo-llms --dir src/content/blog --site https://example.com [--title ..] [--description ..] [--out llms.txt]'); process.exit(1); } const pages = await collectPages(a.dir, a.site); diff --git a/skills/astro-seo/scripts/robots.mjs b/skills/astro-seo/scripts/robots.mjs index caf0fbd..d4161e8 100755 --- a/skills/astro-seo/scripts/robots.mjs +++ b/skills/astro-seo/scripts/robots.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { writeFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; export function renderRobots({ sitemap = "/sitemap.xml", disallowAI = false } = {}) { const lines = ["User-agent: *", "Allow: /", ""]; @@ -22,12 +24,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.site) { - console.error("Usage: node robots.mjs --site https://example.com [--sitemap /sitemap.xml] [--disallow-ai] [--out public/robots.txt]"); + console.error("Usage: npx astro-seo-robots --site https://example.com [--sitemap /sitemap.xml] [--disallow-ai] [--out public/robots.txt]"); process.exit(1); } const sitemap = (a.sitemap || "/sitemap.xml").startsWith("http") diff --git a/skills/astro-seo/scripts/sitemap.mjs b/skills/astro-seo/scripts/sitemap.mjs index 9650fbf..ad248d0 100755 --- a/skills/astro-seo/scripts/sitemap.mjs +++ b/skills/astro-seo/scripts/sitemap.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { writeFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; import { collectPages } from "./llms-txt.mjs"; export function renderSitemap(pages, { changefreq = "weekly" } = {}) { @@ -19,12 +21,20 @@ function args(argv) { return out; } -const isCli = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop()); +const isCli = (() => { + try { + // npm runs bins through a node_modules/.bin symlink: process.argv[1] is the + // link, import.meta.url is the real file. Compare resolved paths, not suffixes. + return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + return false; + } +})(); if (isCli) { const a = args(process.argv.slice(2)); if (!a.dir || !a.site) { - console.error("Usage: node sitemap.mjs --dir src/content/blog --site https://example.com [--out public/sitemap.xml]"); + console.error("Usage: npx astro-seo-sitemap --dir src/content/blog --site https://example.com [--out public/sitemap.xml]"); process.exit(1); } const pages = await collectPages(a.dir, a.site);