From 0dd1ce8b0f2abaf115e0b73c568f8c439b247b18 Mon Sep 17 00:00:00 2001 From: RelayStack <181901420+RelayStack@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:09:54 +0200 Subject: [PATCH] fix(bins): stop every npx bin from silently no-op'ing; add Astro integration (0.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI guard compared import.meta.url against the basename of process.argv[1]. npm installs bins as node_modules/.bin symlinks, so argv[1] is the link (astro-seo-llms) while import.meta.url is the real file (llms-txt.mjs) — the comparison never matched, so every bin and `npm run` build hook exited 0 and generated nothing. Resolve argv[1] with realpathSync + pathToFileURL instead, and cover it with a regression test that executes a bin through a symlink, the way npm does. Integration: - astro-seo-toolkit/integration writes llms.txt, llms-full.txt, sitemap.xml and robots.txt during astro:build:done, from one or more content dirs (deduped by URL). Exposes generate/writeFiles/normalizeOptions. - new bins: astro-seo-toolkit (every file at once), astro-seo-check. - usage hints print the npx form. Repo: - scripts/sync-skill.mjs generates the skill's vendored copies and restores the executable bit; `npm run check:skill` fails on drift. - README: who-it's-for, the integration section, a comparison table. Documents that npm 12 blocks `github:` specs (allow-git=none), so the old `npx -y github:RelayStack/...` snippet is replaced with a clone that actually runs. - CHANGELOG/CITATION 0.5.0, CODE_OF_CONDUCT, CONTRIBUTING bin rule, SKILL.md integration flow, examples/astro on the integration, social card redesigned and exported to PNG. tests: 33 (was 27) --- .gitignore | 15 ++ CHANGELOG.md | 11 + CITATION.cff | 3 +- CODE_OF_CONDUCT.md | 83 ++++++++ CONTRIBUTING.md | 7 +- README.md | 215 +++++++++++++++----- docs/social-card.svg | 43 +++- docs/social-preview.png | Bin 0 -> 79964 bytes examples/astro/README.md | 26 ++- examples/astro/astro.config.mjs | 8 + examples/astro/package.json | 10 +- llms.txt | 10 +- package.json | 65 +++++- scripts/sync-skill.mjs | 55 +++++ seo-toolkit/README.md | 43 +++- seo-toolkit/check.mjs | 14 +- seo-toolkit/indexnow.mjs | 15 +- seo-toolkit/integration.mjs | 176 ++++++++++++++++ seo-toolkit/llms-full.mjs | 14 +- seo-toolkit/llms-txt.mjs | 14 +- seo-toolkit/package.json | 15 +- seo-toolkit/robots.mjs | 14 +- seo-toolkit/sitemap.mjs | 14 +- seo-toolkit/test.mjs | 101 +++++++++ skills/astro-seo/SKILL.md | 23 ++- skills/astro-seo/references/astro-wiring.md | 2 +- skills/astro-seo/scripts/check.mjs | 14 +- skills/astro-seo/scripts/indexnow.mjs | 15 +- skills/astro-seo/scripts/integration.mjs | 176 ++++++++++++++++ skills/astro-seo/scripts/llms-full.mjs | 14 +- skills/astro-seo/scripts/llms-txt.mjs | 14 +- skills/astro-seo/scripts/robots.mjs | 14 +- skills/astro-seo/scripts/sitemap.mjs | 14 +- 33 files changed, 1132 insertions(+), 125 deletions(-) create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 docs/social-preview.png create mode 100644 scripts/sync-skill.mjs create mode 100755 seo-toolkit/integration.mjs create mode 100755 skills/astro-seo/scripts/integration.mjs 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 0000000000000000000000000000000000000000..204487e602b5f6b33281b90cdb703aca27f912b4 GIT binary patch literal 79964 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 Z0ga{PY-_-lr4j^wYD!v)`EW$w{{hoEjjsRz literal 0 HcmV?d00001 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);