agentleFS
jdevalkjdevalk/skills/wp-static-clone/AGENTS.md

skills / wp-static-clone

AGENTS.md100 starsChanged 7 months agoMITView on GitHub

Detailed commands and recipes for each phase. Read this when you're implementing a specific step. Parent SKILL.md has the workflow, gotchas, and output structure. Fetch the index, then any child sitemaps it references, then concatenate page URLs into urls.txt. Adjust grep on the <loc> extraction if the source uses non-Yoast naming. Single invocation over urls.txt. The browser UA + Accept / Accept-Language headers are mandatory — Cloudflare's bot protection 403s the default Wget/1.x UA. If a non-English source has localised…

The file

We split this AGENTS.md into the rules it gives an agent

4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    If a non-English source has localised content negotiation, swap Accept-Language accordingly.

    line 59
  2. Very specific:

    Leave the <a href="https://sitemaps.org"> link further down — it's still factually correct.

    line 91
  3. Very specific:

    After scripts/strip-wp-runtime.py and scripts/selfhost-gravatars.py, audit for any source-domain URLs left:

    line 120
  4. Very specific:

    If the site has any URL changes (slug changes, dropped /page/N/ archive pagination, redirect from /category/<term>/ to a flat /blog/, …

    line 184

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

wp-static-clone — Implementation recipes

Detailed commands and recipes for each phase. Read this when you're implementing a specific step. Parent SKILL.md has the workflow, gotchas, and output structure.

Phase 1 — Sitemaps and Yoast XSL

Fetch the index, then any child sitemaps it references, then concatenate page URLs into urls.txt. Adjust grep on the <loc> extraction if the source uses non-Yoast naming.

mkdir -p output
ROOT=https://example.com   # source site origin

curl -s -A "Mozilla/5.0" "$ROOT/sitemap_index.xml" \
  | tee output/sitemap_index.xml \
  | grep -oE '<loc>[^<]+</loc>' \
  | sed -E 's|</?loc>||g' \
  | grep -v 'image-sitemap' \
  > child-sitemaps.txt

while read -r child; do
    name=$(basename "$child")
    curl -s -A "Mozilla/5.0" "$child" -o "output/$name"
    grep -oE '<loc>[^<]+</loc>' "output/$name" \
        | sed -E 's|</?loc>||g'
done < child-sitemaps.txt | sort -u > urls.txt

# Yoast XSL (purely so sitemaps render in browser; search engines parse XML directly)
mkdir -p output/wp-content/plugins/wordpress-seo/css
curl -s -A "Mozilla/5.0" \
    "$ROOT/wp-content/plugins/wordpress-seo/css/main-sitemap.xsl" \
    -o output/wp-content/plugins/wordpress-seo/css/main-sitemap.xsl

Phase 2 — One-shot wget

Single invocation over urls.txt. The browser UA + Accept / Accept-Language headers are mandatory — Cloudflare's bot protection 403s the default Wget/1.x UA.

wget -p -k -E -nH \
    --restrict-file-names=unix \
    -e robots=off \
    -U "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" \
    --header="Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
    --header="Accept-Language: en,*;q=0.5" \
    --wait=0.3 --random-wait \
    -i urls.txt -P output/

Flags:

  • -p page requisites (CSS / JS / images / fonts referenced from HTML)
  • -k convert links to local
  • -E add .html extension; /foo/ → foo/index.html
  • -nH skip the host directory wrapper
  • --restrict-file-names=unix keep filenames clean on macOS / Linux
  • -e robots=off ignore robots.txt
  • --wait=0.3 --random-wait paces requests so Cloudflare doesn't escalate to 403 partway through

If a non-English source has localised content negotiation, swap Accept-Language accordingly. Language doesn't matter to anything else in the pipeline (the runtime-strip and reply-link regexes match by class, not text).

Phase 3 — Pull missing absolutely-referenced assets

wget -p follows <script src>, <link rel="stylesheet">, <img src> / srcset, <source>, etc. It does not follow og:image, apple-touch-icon, JSON-LD image / logo, msapplication-TileImage, or <link rel="modulepreload">. Audit and fetch the long tail across all three asset roots:

SRC=example.com
grep -rhoE "https?://$SRC/wp-content/(uploads|themes|plugins)/[^\"<'> ]*" output/ \
    | sort -u > missing.txt

wget -x -nH -e robots=off \
    -U "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ..." \
    -i missing.txt -P output/

-x (force directories) is important so files land at their original paths inside wp-content/....

Phase 5 — Yoast XSL attribution edit

import pathlib

p = pathlib.Path('output/wp-content/plugins/wordpress-seo/css/main-sitemap.xsl')
old = ('Generated by <a href="https://yoa.st/1y5" target="_blank" '
       'rel="noopener">Yoast SEO</a>, this is an XML Sitemap, meant '
       'for consumption by search engines.')
new = ('Generated statically and deployed via a static host, this is an XML '
       'Sitemap, meant for consumption by search engines.')
p.write_text(p.read_text(encoding='utf-8').replace(old, new), encoding='utf-8')

Leave the <a href="https://sitemaps.org"> link further down — it's still factually correct.

Phase 6 — Plausible analytics

WordPress's Plausible plugin proxies the script through /wp-content/uploads/<hash>/pa-XXX.js and posts events back to /wp-json/.../v1/.... Replace the entire two-script block with the standard tracker:

import pathlib, re

PATTERN = re.compile(
    r'<script[^>]*id="plausible-analytics-js"></script>\s*'
    r'<script id="plausible-analytics-js-after">.*?</script>',
    re.DOTALL,
)
REPLACEMENT = (
    '<script defer data-domain="example.com" '
    'src="https://plausible.io/js/script.js"></script>'
)
for path in pathlib.Path('output').rglob('*.html'):
    text = path.read_text(encoding='utf-8')
    new = PATTERN.sub(REPLACEMENT, text)
    if new != text:
        path.write_text(new, encoding='utf-8')

Then delete the now-unused proxy file at output/wp-content/uploads/<hash>/pa-*.js.

Phase 6 — Audit remaining absolute URLs

After scripts/strip-wp-runtime.py and scripts/selfhost-gravatars.py, audit for any source-domain URLs left:

SRC=example.com
grep -hE "https?://$SRC/" --include="*.html" -r output/ \
    | grep -vE 'rel="canonical"|og:url|"@id"|"url"|"item"|wp-json/oembed' \
    | sort -u

Anything left after the canonical / JSON-LD allow-list is something to triage. Common offenders:

  • Author archive links (<a class="wp-block-post-author-name__link" href=".../author/<slug>/">). On single-author sites WordPress often 301s these to the homepage. Probe with curl -sI -o /dev/null -w "%{redirect_url}\n" <author-url>. If they redirect to /, strip the <a> wrapper and keep the inner text — don't waste a directory on a duplicate of the home page.
  • modulepreload absolute URLs (<link rel="modulepreload" href="https://<domain>/wp-includes/...">). Convert to root-relative and fetch the underlying file (wget -p doesn't follow modulepreload).
  • Server-rendered iframes pointing at custom PHP endpoints (e.g. <iframe src="/<custom>/<script>.php">). These won't render statically. Drop the wrapping <p>.
  • Gravity Forms script blocks — wrappers have inline <script> tags (no id, just bare <script> tags referencing gform.*) above and below the form, plus an in-<head> polyfill. Strip them all — match <script>...</script> whose body mentions gform. Otherwise the page errors at load when gform is undefined.
  • gform_not_found paragraphs — pre-existing brokenness on the live site (failed shortcode render). Strip the paragraph and any orphaned lead-in heading.
  • Page archive pagination anchors (<a href=".../page/N/">). The /page/N/ URLs aren't in the sitemap, so the targets don't exist. Strip the link, keep the anchor text only.

Phase 7 — robots.txt

ROOT=https://example.com
curl -s -A "Mozilla/5.0" "$ROOT/robots.txt" -o output/robots.txt

# If you flatten output/* into the repo root, the sitemap reference is fine.
# If you keep things under output/ and configure the host accordingly, leave alone.
# If the deployed sitemap path differs, edit:
#   sed -i.bak -E 's|^Sitemap: .*$|Sitemap: https://new.example.com/sitemap_index.xml|' output/robots.txt
# (Delete the .bak after — `-i.bak` is the portable form that works on both BSD and GNU sed.)

Phase 8 — Verify

cd output && python3 -m http.server 8765 &
PORT=8765

# 1. Every URL in urls.txt resolves
while read -r url; do
    path=$(echo "$url" | sed -E 's|^https?://[^/]+||; s|/?$|/|')
    code=$(curl -sI -o /dev/null -w "%{http_code}" "http://localhost:$PORT$path")
    [ "$code" = "200" ] || echo "MISS $code  $path"
done < ../urls.txt

# 2. No remaining absolute source-domain URLs outside the allow-list
grep -hE "https?://example\.com/" --include="*.html" -r . \
    | grep -vE 'rel="canonical"|og:url|"@id"|"url"|"item"|wp-json/oembed' \
    | head

# 3. Spider-crawl for broken internal links
wget --spider -r -nd --no-parent -l 5 "http://localhost:$PORT/" 2>&1 \
    | grep -E '404|broken'

Then open the homepage and one deep page in a browser. Watch srcset images, sidebar widgets, the header banner — those break silently if missed.

Phase 9 — Deploy

Pick the recipe matching the target host from Phase 0.

Cloudflare Pages

Defaults: no build command, no build output directory, no root directory. Pages serves the repo root.

If the site has any URL changes (slug changes, dropped /page/N/ archive pagination, redirect from /category/<term>/ to a flat /blog/, etc.), add _redirects at the repo root:

/old-path/        /new-path/        301
/category/*       /blog/            301

Long-cache the hashed assets via _headers:

/wp-content/*
  Cache-Control: public, max-age=31536000, immutable

/wp-includes/*
  Cache-Control: public, max-age=31536000, immutable

Push to GitHub and Pages auto-deploys. gh repo create <user>/<repo> --private --source=. --push works.

Netlify

Same _redirects and _headers syntax as Cloudflare Pages. Optional netlify.toml:

[build]
publish = "."

Connect the repo via the Netlify dashboard.

Vercel

vercel.json at the repo root:

{
    "redirects": [
        { "source": "/old-path/", "destination": "/new-path/", "permanent": true }
    ],
    "headers": [
        {
            "source": "/wp-content/(.*)",
            "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
        }
    ],
    "cleanUrls": false,
    "trailingSlash": true
}

trailingSlash: true matches WordPress's default URL shape (and what wget -E produces). If the source uses no trailing slashes, set to false.

Generic (nginx)

server {
    listen 80;
    server_name example.com;
    root /var/www/wp-static-clone;
    index index.html;

    # Trailing-slash URLs map to <slug>/index.html
    location / {
        try_files $uri $uri/ $uri/index.html =404;
    }

    location ~ ^/wp-content/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Generic (Apache)

.htaccess at the document root:

Options +MultiViews
DirectoryIndex index.html

<FilesMatch "^.+\.(jpg|jpeg|png|webp|svg|css|js|woff2?)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

ErrorDocument 404 /404.html

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.