From 5fa9e31f1461a53665f93e82e4dd7d5acba547f3 Mon Sep 17 00:00:00 2001 From: RadiusDeck Dev Date: Fri, 23 Oct 2026 17:32:53 +0700 Subject: [PATCH] feat: add article layout, guide index, and search metadata - Add the post layout with author, publication, update, and last-tested dates, and a related guides block. - Add /blog/ and /about/ pages and header navigation. - Add title, description, canonical, Open Graph, feed link, and JSON-LD (TechArticle for articles, WebSite for the homepage) to every page. - Generate sitemap.xml, an Atom feed, and robots.txt with Liquid templates instead of plugins. Keep 404 noindex and out of the sitemap. - Extend check_site.py to verify page metadata, sitemap coverage, robots.txt, the feed, and JSON-LD. - Keep article drafts out of Git and document the drafts workflow. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + 404.html | 2 + README.md | 70 +++++++++++++++--- _config.yml | 10 +++ _includes/head.html | 55 ++++++++++++++ _layouts/default.html | 17 ++--- _layouts/post.html | 37 ++++++++++ about.md | 44 ++++++++++++ assets/css/main.css | 41 +++++++++++ blog.md | 28 ++++++++ feed.xml | 24 +++++++ index.md | 19 ++++- robots.txt | 9 +++ scripts/check_site.py | 163 +++++++++++++++++++++++++++++++++++++----- sitemap.xml | 24 +++++++ 15 files changed, 507 insertions(+), 39 deletions(-) create mode 100644 _includes/head.html create mode 100644 _layouts/post.html create mode 100644 about.md create mode 100644 blog.md create mode 100644 feed.xml create mode 100644 robots.txt create mode 100644 sitemap.xml diff --git a/.gitignore b/.gitignore index adb0b1b..1dccbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ /vendor/ __pycache__/ *.pyc + +# Article drafts stay local until release; see README "Drafts". +/_drafts/ diff --git a/404.html b/404.html index 4ed1dd1..77286e4 100644 --- a/404.html +++ b/404.html @@ -1,6 +1,8 @@ --- title: Page not found permalink: /404.html +sitemap: false +robots: noindex ---

404

Page not found.

diff --git a/README.md b/README.md index e2c1e71..c77b4d5 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,34 @@ JEKYLL_ENV=production bundle _2.6.9_ exec jekyll build --strict_front_matter python3 scripts/check_site.py ``` -The checker requires the homepage and 404 page, then verifies local `href` and -`src` targets and HTML fragment IDs. It does not check external URLs or CSS URLs. -Also inspect the homepage on a narrow screen before publishing. +The checker requires the homepage, 404 page, `sitemap.xml`, `feed.xml`, and +`robots.txt`. It verifies local `href` and `src` targets and HTML fragment IDs. +For every page except 404, it requires a title, a meta description, a canonical +URL that matches the page, and no `noindex`; the 404 page must be `noindex`. +The sitemap must list exactly the indexable pages, `robots.txt` must point to it, +and JSON-LD blocks must parse. External URLs and CSS URLs are not checked. +Also inspect new or changed pages at phone width before publishing. -The initial production build, frozen dependency check, and generated local -links passed on 2026-10-23 with Ruby 3.4.12 and Bundler 2.6.9. Browser rendering -and GitHub-hosted workflows have not yet been verified. +The production build, frozen dependency check, and checker passed on +2026-10-23 with Ruby 3.4.12 and Bundler 2.6.9. The same day, the homepage, +`/blog/`, `/about/`, 404, and a draft article were rendered in headless Chromium +at 375 px and 1280 px without horizontal page scrolling. The GitHub-hosted +workflow runs have not yet been reviewed. + +## Drafts + +Article drafts live in `_drafts/`, which is ignored by Git, so unfinished text +does not appear in this public repository. Preview and check them locally: + +```bash +bundle _2.6.9_ exec jekyll serve --drafts +JEKYLL_ENV=production bundle _2.6.9_ exec jekyll build --strict_front_matter --drafts +python3 scripts/check_site.py +``` + +The workflows build without `--drafts`, so CI does not check draft content. +To publish, move the reviewed file to `_posts/YYYY-MM-DD-.md` and add it +in the release pull request. The URL stays `/blog//`. ## Publishing @@ -57,8 +78,8 @@ GitHub Actions**. This setting has already been selected by the repository owner - The deployment uses the `github-pages` environment and the built-in GitHub token. Configure the environment's deployment branch rule to allow only `main`. -Pushing this scaffold to `main` will attempt to publish the initial homepage. -The first public deployment has not yet been verified. +The first deployment of the initial homepage to +was confirmed by the repository owner. ## Troubleshooting and rollback @@ -69,7 +90,34 @@ deployment fails, check the Pages source and `github-pages` environment rules. To undo a published change, revert its commit, verify the resulting build, and push the revert to `main`. This triggers publication of the restored content. -## Next stage +## Site structure + +| Path | Purpose | +| --- | --- | +| `_layouts/default.html` | Page shell: header navigation, footer | +| `_layouts/post.html` | Article: title, author, publication, update and test dates, related guides | +| `_includes/head.html` | Title, description, canonical URL, Open Graph, feed link, JSON-LD (`TechArticle` for articles, `WebSite` for the homepage) | +| `blog.md`, `about.md` | `/blog/` list of guides and `/about/` page | +| `sitemap.xml`, `feed.xml`, `robots.txt` | Liquid templates; no Jekyll plugins are used | + +Article front matter: + +```yaml +title: "FreeRADIUS clients.conf: Examples and Best Practices" +description: One or two sentences for search results and the guide list. +last_modified_at: 2026-11-02 # only after a substantial update +tested: + date: 2026-10-23 + environment: Official FreeRADIUS Docker image + freeradius: "3.2.10" + radiusdeck: "0.1.0" +related: + - /blog// +``` + +The author (`RadiusDeck team`) and the `post` layout come from `_config.yml` +defaults. The layout prints the title as the page heading, so the article body +starts without an H1. Related guides appear only when the target article is +published. -Add the article layout, blog index, About page, sitemap, RSS, and the first three -reviewed guides. Article URLs follow `/blog//`. +`robots.txt` allows all crawlers, including AI search crawlers. diff --git a/_config.yml b/_config.yml index 48d7ef2..c886102 100644 --- a/_config.yml +++ b/_config.yml @@ -13,11 +13,21 @@ strict_front_matter: true future: false show_drafts: false +author: + name: RadiusDeck team + url: /about/ +product_url: https://github.com/radiusdeck/radiusdeck + defaults: - scope: path: "" values: layout: default + - scope: + path: "" + type: posts + values: + layout: post exclude: - README.md diff --git a/_includes/head.html b/_includes/head.html new file mode 100644 index 0000000..ea5cc4d --- /dev/null +++ b/_includes/head.html @@ -0,0 +1,55 @@ +{%- assign description = page.description | default: site.description | strip_html | normalize_whitespace -%} +{%- assign canonical = page.url | replace: "index.html", "" | absolute_url -%} +{%- assign is_post = false -%} +{%- if page.collection == "posts" -%}{%- assign is_post = true -%}{%- endif -%} +{%- assign modified = page.last_modified_at | default: page.date -%} + + +{{ page.title | escape }} | {{ site.title | escape }} + + +{%- if page.robots %} + +{%- endif %} + + + + + +{%- if is_post %} + + + +{%- else %} + +{%- endif %} + + + +{%- if is_post %} + +{%- elsif page.url == "/" %} + +{%- endif %} diff --git a/_layouts/default.html b/_layouts/default.html index 563872c..e2b4cae 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -1,24 +1,25 @@ - - - {{ page.title | escape }} | {{ site.title | escape }} - - - + {% include head.html %}
{{ content }}
diff --git a/_layouts/post.html b/_layouts/post.html new file mode 100644 index 0000000..d4d9cf0 --- /dev/null +++ b/_layouts/post.html @@ -0,0 +1,37 @@ +--- +layout: default +--- +{%- assign date_format = "%B %-d, %Y" -%} +
+
+

{{ page.title | escape }}

+ +
+ + {{ content }} + + {%- assign related_posts = "" | split: "" -%} + {%- for url in page.related -%} + {%- assign match = site.posts | where: "url", url | first -%} + {%- if match -%}{%- assign related_posts = related_posts | push: match -%}{%- endif -%} + {%- endfor -%} + {%- if related_posts.size > 0 %} + + {%- endif %} +
diff --git a/about.md b/about.md new file mode 100644 index 0000000..f1f7ca2 --- /dev/null +++ b/about.md @@ -0,0 +1,44 @@ +--- +title: About +description: >- + Who writes the RadiusDeck Blog, how the FreeRADIUS guides are tested, and how + to report a correction. +permalink: /about/ +--- + +# About this blog + +The RadiusDeck Blog publishes practical guides for administrators who connect +network devices, such as access points and VPN gateways, to FreeRADIUS. The articles are +written by the RadiusDeck team, the maintainers of +[RadiusDeck]({{ site.product_url }}), an open-source web interface for +managing FreeRADIUS clients in `clients.conf`. + +## How the guides are tested + +- Commands and configuration examples are run on an isolated test setup before + publication. Each article lists the software versions and the date of the + last technical check. +- Behavior that depends on a version, distribution, or deployment is labeled as + such. Defaults can change between FreeRADIUS releases, so check them against + your own installation. +- Addresses come from documentation ranges such as `192.0.2.0/24`, and device + names and shared secrets are fictional. Replace them with your own values. +- Technical claims link to primary sources, such as FreeRADIUS configuration + comments, manual pages, and project announcements. + +When a guide is revised, it keeps its address and shows the date of the update. + +## What RadiusDeck covers + +RadiusDeck manages RADIUS clients: the network devices that send requests to +FreeRADIUS. End-user accounts, SQL, and authentication policies are configured +in FreeRADIUS itself. The guides say when a step is done in FreeRADIUS and when +RadiusDeck can help with it. + +## Corrections + +If a guide is wrong or outdated for your version, open an issue in the +[blog repository](https://github.com/radiusdeck/radiusdeck.github.io/issues). +Include the FreeRADIUS version and remove shared secrets, passwords, and +private addresses before posting. diff --git a/assets/css/main.css b/assets/css/main.css index de4afba..8e99caf 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -42,3 +42,44 @@ code { overflow-wrap: anywhere; background: #e7ede8; padding: .1em .25em; border :focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } .skip-link { position: absolute; top: -5rem; left: 1rem; padding: .5rem 1rem; background: white; } .skip-link:focus { top: .5rem; } + +/* Navigation */ +.site-nav { display: flex; flex-wrap: wrap; gap: .4rem 1.25rem; } +.site-nav a { color: var(--ink); text-decoration: none; } +.site-nav a:hover, .site-nav a[aria-current="page"] { color: var(--accent); text-decoration: underline; } +.site-footer p { margin: 0; } + +/* Articles */ +.post > * { max-width: 46rem; } +.post-header h1 { font-size: clamp(2rem, 5vw, 3.2rem); letter-spacing: -.03em; } +.post-meta { color: var(--muted); font-size: .9rem; } +h3 { font-size: 1.1rem; line-height: 1.35; margin-top: 2rem; } +#markdown-toc { padding: 1rem 1rem 1rem 2.25rem; border: 1px solid var(--line); border-radius: .3rem; font-size: .95rem; } +#markdown-toc ul { padding-left: 1.25rem; } +pre { + overflow-x: auto; + padding: .9rem 1rem; + background: #e7ede8; + border-radius: .3rem; + font-size: .88rem; + line-height: 1.55; + tab-size: 4; +} +pre code { padding: 0; background: none; overflow-wrap: normal; white-space: pre; } +table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: .95rem; } +th, td { padding: .45rem .75rem; border: 1px solid var(--line); text-align: left; vertical-align: top; } +th { background: #e7ede8; } +td code { overflow-wrap: normal; white-space: nowrap; } +li > p { margin-block: .5rem; } +.related { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); } + +/* Lists of guides */ +.post-list { list-style: none; padding: 0; } +.post-list li { padding-block: 1rem; border-bottom: 1px solid var(--line); } +.post-list h2, .post-list h3 { margin: 0; } +.post-list p { margin-block: .35rem; } + +@media (max-width: 30rem) { + .wrapper { width: min(100% - 2rem, 68rem); } + body { font-size: 1rem; } +} diff --git a/blog.md b/blog.md new file mode 100644 index 0000000..0fa72b4 --- /dev/null +++ b/blog.md @@ -0,0 +1,28 @@ +--- +title: FreeRADIUS guides +description: >- + Tested FreeRADIUS guides on clients.conf, RADIUS client troubleshooting, + and managing clients with the RadiusDeck web interface. +permalink: /blog/ +--- + +# FreeRADIUS guides + +Each guide is checked on an isolated test setup. The tested versions and the +date of the last check are listed at the top of every article. +{: .lead } + +{% if site.posts.size > 0 -%} +
    +{%- for post in site.posts %} +
  • +

    {{ post.title | escape }}

    +

    {{ post.description | strip_html | normalize_whitespace | escape }}

    + +
  • +{%- endfor %} +
+{%- else -%} +The first guides are in preparation. In the meantime, see the +[RadiusDeck documentation]({{ site.product_url }}/tree/main/docs/community). +{%- endif %} diff --git a/feed.xml b/feed.xml new file mode 100644 index 0000000..9bfecab --- /dev/null +++ b/feed.xml @@ -0,0 +1,24 @@ +--- +layout: null +sitemap: false +--- + + + {{ site.title | xml_escape }} + {{ site.description | strip | xml_escape }} + + + {{ '/' | absolute_url }} + {{ site.time | date_to_xmlschema }} + {{ site.author.name | xml_escape }}{{ site.author.url | absolute_url }} +{%- for post in site.posts limit: 20 %} + + {{ post.title | xml_escape }} + + {{ post.url | absolute_url }} + {{ post.date | date_to_xmlschema }} + {{ post.last_modified_at | default: post.date | date_to_xmlschema }} + {{ post.description | strip_html | normalize_whitespace | xml_escape }} + +{%- endfor %} + diff --git a/index.md b/index.md index 4836160..f356d06 100644 --- a/index.md +++ b/index.md @@ -22,11 +22,24 @@ that walks through adding a client and checking a real authentication request. [Explore RadiusDeck on GitHub →](https://github.com/radiusdeck/radiusdeck){: .button } -## Guides in preparation - +## Guides + +{% if site.posts.size > 0 -%} +
    +{%- for post in site.posts limit: 3 %} +
  • +

    {{ post.title | escape }}

    +

    {{ post.description | strip_html | normalize_whitespace | escape }}

    +
  • +{%- endfor %} +
+ +[All guides →]({{ '/blog/' | relative_url }}) +{%- else -%} Our first guides will cover `clients.conf` examples, diagnosing unknown client errors, and managing RADIUS clients through the web interface. In the meantime, browse the -[Community documentation](https://github.com/radiusdeck/radiusdeck/tree/main/docs/community) +[Community documentation]({{ site.product_url }}/tree/main/docs/community) for configuration and operating details. +{%- endif %} diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..da03644 --- /dev/null +++ b/robots.txt @@ -0,0 +1,9 @@ +--- +layout: null +sitemap: false +--- +# All crawlers, including search and AI answer engines, may read the site. +User-agent: * +Allow: / + +Sitemap: {{ '/sitemap.xml' | absolute_url }} diff --git a/scripts/check_site.py b/scripts/check_site.py index f0f0807..00663b8 100644 --- a/scripts/check_site.py +++ b/scripts/check_site.py @@ -1,38 +1,84 @@ -"""Check generated HTML and local file links without network dependencies.""" +"""Check generated HTML, local links, and search metadata without network dependencies.""" from html.parser import HTMLParser +import json from pathlib import Path +import re import sys from urllib.parse import unquote, urlsplit +import xml.etree.ElementTree as ET +SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" +# Pages that must not be indexed or listed in the sitemap. +NOT_INDEXED = {"404.html"} -class Links(HTMLParser): + +class Page(HTMLParser): def __init__(self) -> None: super().__init__() self.urls: list[str] = [] self.ids: set[str] = set() + self.meta: dict[str, str] = {} + self.canonical: str | None = None + self.title = "" + self.json_ld: list[str] = [] + self._in_title = False + self._in_json_ld = False def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - for key, value in attrs: - if value is None: - continue + values = {key: value for key, value in attrs if value is not None} + for key, value in values.items(): if key == "id" or (tag == "a" and key == "name"): self.ids.add(value) if key in {"href", "src"}: self.urls.append(value) + if tag == "meta" and "content" in values: + name = values.get("name") or values.get("property") + if name: + self.meta[name] = values["content"] + if tag == "link" and values.get("rel") == "canonical": + self.canonical = values.get("href") + self._in_title = tag == "title" + if tag == "script" and values.get("type") == "application/ld+json": + self._in_json_ld = True + self.json_ld.append("") + def handle_endtag(self, tag: str) -> None: + if tag == "title": + self._in_title = False + if tag == "script": + self._in_json_ld = False -def check(root: Path) -> list[str]: - root = root.resolve() + def handle_data(self, data: str) -> None: + if self._in_title: + self.title += data + if self._in_json_ld: + self.json_ld[-1] += data + + +def site_url(config: Path) -> str: + # Plain, single- or double-quoted scalar, optionally followed by a comment. + match = re.search( + r"""^url:[ \t]*(["']?)([^"'\s#]+)\1[ \t]*(?:#.*)?$""", + config.read_text(encoding="utf-8"), + re.M, + ) + if not match: + raise SystemExit(f"No url in {config}") + return match.group(2).rstrip("/") + + +def page_url(base: str, root: Path, path: Path) -> str: + relative = path.relative_to(root).as_posix() + if relative == "index.html": + return base + "/" + if relative.endswith("/index.html"): + return f"{base}/{relative[: -len('index.html')]}" + return f"{base}/{relative}" + + +def check_links(root: Path, pages: dict[Path, Page]) -> list[str]: errors = [] - for required in ("index.html", "404.html"): - if not (root / required).is_file(): - errors.append(f"Missing {required}") - pages = {} - for path in root.rglob("*.html"): - parser = Links() - parser.feed(path.read_text(encoding="utf-8")) - pages[path] = parser for path, page in pages.items(): for value in page.urls: url = urlsplit(value) @@ -57,9 +103,92 @@ def check(root: Path) -> list[str]: return errors +def check_metadata(base: str, root: Path, pages: dict[Path, Page]) -> list[str]: + errors = [] + for path, page in pages.items(): + name = path.relative_to(root).as_posix() + robots = page.meta.get("robots", "") + if name in NOT_INDEXED: + if "noindex" not in robots: + errors.append(f"{name}: expected noindex") + continue + if "noindex" in robots: + errors.append(f"{name}: unexpected noindex") + if not page.title.strip(): + errors.append(f"{name}: empty title") + if not page.meta.get("description", "").strip(): + errors.append(f"{name}: missing meta description") + if page.canonical != page_url(base, root, path): + errors.append(f"{name}: canonical {page.canonical!r} does not match page URL") + for block in page.json_ld: + try: + json.loads(block) + except json.JSONDecodeError as error: + errors.append(f"{name}: invalid JSON-LD: {error}") + return errors + + +def check_sitemap(base: str, root: Path, pages: dict[Path, Page]) -> list[str]: + try: + tree = ET.parse(root / "sitemap.xml") + except ET.ParseError as error: + return [f"sitemap.xml: invalid XML: {error}"] + errors = [] + listed = {loc.text or "" for loc in tree.iter(f"{SITEMAP_NS}loc")} + expected = { + page_url(base, root, path) + for path in pages + if path.relative_to(root).as_posix() not in NOT_INDEXED + } + for url in sorted(listed - expected): + errors.append(f"sitemap.xml: URL without an indexable page: {url}") + for url in sorted(expected - listed): + errors.append(f"sitemap.xml: page missing from sitemap: {url}") + return errors + + +def check_robots(base: str, root: Path) -> list[str]: + lines = [line.strip() for line in (root / "robots.txt").read_text(encoding="utf-8").splitlines()] + errors = [] + if f"Sitemap: {base}/sitemap.xml" not in lines: + errors.append("robots.txt: missing absolute Sitemap line") + if "Disallow: /" in lines: + errors.append("robots.txt: blocks the whole site") + return errors + + +def check_feed(root: Path) -> list[str]: + try: + ET.parse(root / "feed.xml") + except ET.ParseError as error: + return [f"feed.xml: invalid XML: {error}"] + return [] + + +def check(root: Path, config: Path) -> list[str]: + root = root.resolve() + required = ("index.html", "404.html", "sitemap.xml", "feed.xml", "robots.txt") + missing = [f"Missing {name}" for name in required if not (root / name).is_file()] + if missing: + return missing + base = site_url(config) + pages = {} + for path in root.rglob("*.html"): + page = Page() + page.feed(path.read_text(encoding="utf-8")) + pages[path] = page + return ( + check_links(root, pages) + + check_metadata(base, root, pages) + + check_sitemap(base, root, pages) + + check_robots(base, root) + + check_feed(root) + ) + + if __name__ == "__main__": - failures = check(Path(sys.argv[1] if len(sys.argv) > 1 else "_site")) + failures = check(Path(sys.argv[1] if len(sys.argv) > 1 else "_site"), Path("_config.yml")) if failures: print("\n".join(failures), file=sys.stderr) sys.exit(1) - print("Generated pages and local links passed.") + print("Generated pages, local links, and search metadata passed.") diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000..463c1d5 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,24 @@ +--- +layout: null +sitemap: false +--- + + +{%- assign pages = site.html_pages | where_exp: "item", "item.sitemap != false" | sort: "url" %} +{%- for item in pages %} + + {{ item.url | replace: "index.html", "" | absolute_url | xml_escape }} + {%- if item.last_modified_at %} + {{ item.last_modified_at | date_to_xmlschema }} + {%- endif %} + +{%- endfor %} +{%- for post in site.posts %} +{%- unless post.sitemap == false %} + + {{ post.url | absolute_url | xml_escape }} + {{ post.last_modified_at | default: post.date | date_to_xmlschema }} + +{%- endunless %} +{%- endfor %} +