From a5d7bab467225d13d9a833085b52f5a8036610aa Mon Sep 17 00:00:00 2001 From: Bill Stumbo Date: Wed, 11 Nov 2026 08:46:44 -0500 Subject: [PATCH 1/4] SEO Enhancements: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New file - static/images/og-default.png — a 1200×630 default share image (white Interlisp logo + "The Medley Interlisp Project" / "interlisp.org" on the site's #30638e primary color with a subtle gradient). Generated with PIL; 43 KB. config/_default/params.yaml - Replaced the commented-out images line with a live images: [images/og-default.png] default. - Added fediverse_creator: "@interlisp@fosstodon.org". layouts/_partials/hooks/head-end.html (the head injection point) - Emits og:image:alt and twitter:image:alt, derived from the page title (falling back to site title), only when an image is present. - Emits , with a page-level fediverse_creator frontmatter override. archetypes/default.md — added commented images: and fediverse_creator: examples for new pages. README.md — new "Social Share Images and Metadata" subsection (plus TOC entry) covering the default image, per-page overrides, alt text, Mastodon attribution, and the Bluesky/Mastodon Open-Graph-only behavior. --- README.md | 22 ++++++++++++++++++++++ archetypes/default.md | 5 +++++ config/_default/params.yaml | 12 ++++++++++-- layouts/_partials/hooks/head-end.html | 16 ++++++++++++++++ static/images/og-default.png | Bin 0 -> 43511 bytes 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 static/images/og-default.png diff --git a/README.md b/README.md index 38c2ecc76..d252e370f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Interlisp and how to access and use the restored Medley system. - [Content Authoring](#content-authoring) - [Editing Existing Pages](#editing-existing-pages) - [Adding New Pages](#adding-new-pages) + - [Social Share Images and Metadata](#social-share-images-and-metadata) - [Adding or Updating the Comments Page](#adding-or-updating-the-comments-page) - [Bibliography Data](#bibliography-data) - [Local Development](#local-development) @@ -120,6 +121,27 @@ type: docs The content follows the preamble and is written using [Markdown](https://www.markdownguide.org/tools/hugo/). +### Social Share Images and Metadata + +When a page is shared on social platforms (Bluesky, Mastodon, Facebook, LinkedIn, Slack, etc.), the preview card is built from the page's Open Graph metadata. Docsy generates these tags automatically, so authors normally don't need to do anything. + +- **Default image:** `config/_default/params.yaml` sets `images: [images/og-default.png]`, which is used for every page that does not specify its own. The image lives at `static/images/og-default.png` and is 1200x630 px. +- **Per-page override:** add an `images` list to the page front matter. Place a 1200x630 px image under `static/` and reference it site-relative: + + ```markdown + --- + title: Medley Goals + weight: 10 + type: docs + images: ["images/medley-goals-share.png"] + --- + ``` + +- **Alt text:** the `og:image:alt` and `twitter:image:alt` values are generated from the page title automatically (see `layouts/_partials/hooks/head-end.html`). +- **Mastodon attribution:** `config/_default/params.yaml` sets `fediverse_creator: "@interlisp@fosstodon.org"`, which is emitted as a `fediverse:creator` meta tag so Mastodon (4.3+) credits the project account on shared links. Override per page with a `fediverse_creator` front matter value if needed. + +Bluesky and Mastodon build their previews from Open Graph tags only (they ignore `twitter:*`). Mastodon caches a card per status, so edit or repost to refresh it. Bluesky generates the card when a post is composed, so metadata changes only affect new posts. + ### Adding or Updating the Comments Page The *comments* page ("What people are saying") collects quotes and screenshots that discuss Medley Interlisp. We preserve Twitter and Mastodon posts as images along with links to the post to protect against content being deleted from social platforms. diff --git a/archetypes/default.md b/archetypes/default.md index 00e77bd79..7795f70f0 100644 --- a/archetypes/default.md +++ b/archetypes/default.md @@ -2,5 +2,10 @@ title: "{{ replace .Name "-" " " | title }}" date: {{ .Date }} draft: true +# Optional: override the default social share image (Open Graph / Twitter Card). +# Place the file under static/ and reference it site-relative, e.g.: +# images: ["images/my-share-image.png"] +# Optional: override the Mastodon fediverse:creator attribution for this page. +# fediverse_creator: "@interlisp@fosstodon.org" --- diff --git a/config/_default/params.yaml b/config/_default/params.yaml index 99603d4fa..375fc53fe 100644 --- a/config/_default/params.yaml +++ b/config/_default/params.yaml @@ -6,8 +6,16 @@ # Set Google Analytics to use GA4 disableGtagForUniversalAnalytics: false -# First one is picked as the Twitter card image if not set on page. -# images = ["images/project-illustration.png"] +# Default social share image (Open Graph / Twitter Card). +# The first entry is used when a page does not set its own `images` front matter. +# Recommended size: 1200x630 px. Served from static/, so the path is site-relative. +images: + - images/og-default.png + +# Mastodon (4.3+) attribution for shared links. Emitted as +# by layouts/_partials/hooks/head-end.html. +# Override per page with a `fediverse_creator` front matter value. +fediverse_creator: "@interlisp@fosstodon.org" # Menu title if your navbar has a versions selector to access old versions of your site. # This menu appears only if you have at least one [params.versions] set. diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html index 25c9439ff..ab1bb76b2 100644 --- a/layouts/_partials/hooks/head-end.html +++ b/layouts/_partials/hooks/head-end.html @@ -13,4 +13,20 @@ {{ if eq .Kind "404" }} {{ end }} +{{- /* Alt text for the social share image. Docsy/Hugo already emit og:image and + twitter:image (from page `images` front matter, falling back to + site.Params.images); add the matching alt text for accessibility. */ -}} +{{- $socialImage := "" -}} +{{- with .Params.images }}{{ $socialImage = index . 0 }}{{ else }}{{ with site.Params.images }}{{ $socialImage = index . 0 }}{{ end }}{{ end -}} +{{- with $socialImage }} +{{- $socialAlt := $.Title | default site.Title }} + + +{{- end }} +{{- /* Mastodon 4.3+ fediverse:creator attribution for shared links. + Page front matter overrides the site-wide value in params.yaml. */ -}} +{{- $fediverseCreator := .Params.fediverse_creator | default site.Params.fediverse_creator -}} +{{- with $fediverseCreator }} + +{{- end }} {{ partial "bibliography-json-ld.html" . }} diff --git a/static/images/og-default.png b/static/images/og-default.png new file mode 100644 index 0000000000000000000000000000000000000000..1a1c332ebb8bc0c81f8ac691acf29d5f63114ae7 GIT binary patch literal 43511 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 Dp%^u$ literal 0 HcmV?d00001 From 500e031432f9ac47571c95c8db6870f33ed79d01 Mon Sep 17 00:00:00 2001 From: Bill Stumbo Date: Wed, 11 Nov 2026 20:38:20 -0500 Subject: [PATCH 2/4] Add Phase 1.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON-LD for non-bibliography pages Four new partials (layouts/_partials/), all following the existing bibliography-json-ld.html output convention (printf + jsonify + safeHTML) - layouts/_partials/hooks/head-end.html — calls the four new partials (each self-guards, so no duplication with the bibliography schema). - content/en/project/faqs.md — added schema: faq. - README.md — new "Structured Data (JSON-LD)" section + TOC entry (schema table, auto-derived Article metadata, FAQ opt-in, validation pointer). - docs/SEO_IMPROVEMENT_PLAN.md — checked off 1.2 tasks, added implementation notes, updated the baseline/gap analysis/success metrics, and logged a change-log entry.# Please enter the commit message for your changes. Lines starting --- README.md | 32 ++ content/en/project/faqs.md | 1 + layouts/_partials/hooks/head-end.html | 6 + layouts/_partials/schema-article.html | 37 ++ layouts/_partials/schema-breadcrumb.html | 32 ++ layouts/_partials/schema-faq.html | 50 +++ layouts/_partials/schema-organization.html | 35 ++ tests/test_site_jsonld.py | 379 +++++++++++++++++++++ 8 files changed, 572 insertions(+) create mode 100644 layouts/_partials/schema-article.html create mode 100644 layouts/_partials/schema-breadcrumb.html create mode 100644 layouts/_partials/schema-faq.html create mode 100644 layouts/_partials/schema-organization.html create mode 100644 tests/test_site_jsonld.py diff --git a/README.md b/README.md index d252e370f..f3df9af10 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Interlisp and how to access and use the restored Medley system. - [Editing Existing Pages](#editing-existing-pages) - [Adding New Pages](#adding-new-pages) - [Social Share Images and Metadata](#social-share-images-and-metadata) + - [Structured Data (JSON-LD)](#structured-data-json-ld) - [Adding or Updating the Comments Page](#adding-or-updating-the-comments-page) - [Bibliography Data](#bibliography-data) - [Local Development](#local-development) @@ -142,6 +143,37 @@ When a page is shared on social platforms (Bluesky, Mastodon, Facebook, LinkedIn Bluesky and Mastodon build their previews from Open Graph tags only (they ignore `twitter:*`). Mastodon caches a card per status, so edit or repost to refresh it. Bluesky generates the card when a post is composed, so metadata changes only affect new posts. +### Structured Data (JSON-LD) + +The site emits [schema.org](https://schema.org) JSON-LD so search engines can show rich results (article cards, breadcrumbs, FAQ accordions). The partials live in `layouts/_partials/` and are wired into `layouts/_partials/hooks/head-end.html`; each one guards itself, so authors normally don't need to do anything. + +| Schema | Where it appears | Source partial | +|--------|------------------|----------------| +| `Organization` | Home page only | `schema-organization.html` | +| `Article` | Every content page (not bibliography entries or sections) | `schema-article.html` | +| `BreadcrumbList` | Every page except the home page and 404 | `schema-breadcrumb.html` | +| `FAQPage` | Pages opted in with `schema: faq` | `schema-faq.html` | +| Bibliography types | `content/en/history/bibliography/**` | `bibliography-json-ld.html` | + +- **Article metadata** is derived automatically: `headline` from the title, `description` from the page description, `datePublished` from `date`, `dateModified` from the file's last git change (`enableGitInfo: true`), and `author`/`publisher` from `site.Params.author`. +- **To mark a page as an FAQ**, add `schema: faq` to its front matter. Each `##` (H2) heading becomes a question and the text beneath it becomes the answer: + + ```markdown + --- + title: FAQs + weight: 20 + type: docs + schema: faq + --- + + ## Is copy and paste supported? + + Yes. Medley supports copying text between the Interlisp environment and your host system. + ``` + + Only use this on genuine question-and-answer pages; search engines may penalize incorrect FAQ markup. +- **Validate locally** with the test suite (`tests/test_site_jsonld.py`) and, before release, with Google's [Rich Results Test](https://search.google.com/test/rich-results). + ### Adding or Updating the Comments Page The *comments* page ("What people are saying") collects quotes and screenshots that discuss Medley Interlisp. We preserve Twitter and Mastodon posts as images along with links to the post to protect against content being deleted from social platforms. diff --git a/content/en/project/faqs.md b/content/en/project/faqs.md index 6ce33a5be..059152e41 100644 --- a/content/en/project/faqs.md +++ b/content/en/project/faqs.md @@ -2,6 +2,7 @@ title: FAQs weight: 20 type: docs +schema: faq --- ## Is copy and paste available? diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html index ab1bb76b2..874b053f9 100644 --- a/layouts/_partials/hooks/head-end.html +++ b/layouts/_partials/hooks/head-end.html @@ -30,3 +30,9 @@ {{- end }} {{ partial "bibliography-json-ld.html" . }} +{{- /* Structured data (JSON-LD) for non-bibliography pages. Each partial + self-guards so only the relevant schema is emitted per page. */ -}} +{{ partial "schema-organization.html" . }} +{{ partial "schema-article.html" . }} +{{ partial "schema-faq.html" . }} +{{ partial "schema-breadcrumb.html" . }} diff --git a/layouts/_partials/schema-article.html b/layouts/_partials/schema-article.html new file mode 100644 index 000000000..6dd899c49 --- /dev/null +++ b/layouts/_partials/schema-article.html @@ -0,0 +1,37 @@ +{{- /* + schema-article.html + Emits Article JSON-LD for regular content pages. + + Skipped for bibliography pages (handled by bibliography-json-ld.html with a + type-specific schema) and redirect stubs. +*/ -}} +{{- if and .IsPage (ne .Type "bibliography") (ne .Type "redirect") -}} +{{- $desc := partial "page-description.html" . -}} +{{- $author := site.Params.author.name | default site.Title -}} +{{- $logo := "Resources/logo_red_no_border_568x385.png" | absURL -}} +{{- $socialImage := "" -}} +{{- with .Params.images }}{{ $socialImage = index . 0 }}{{ else }}{{ with site.Params.images }}{{ $socialImage = index . 0 }}{{ end }}{{ end -}} +{{- $ld := dict + "@context" "https://schema.org" + "@type" "Article" + "headline" (.Title | strings.TrimRight "\n ") + "url" .Permalink + "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink) + "inLanguage" "en-US" + "author" (dict "@type" "Organization" "name" $author) + "publisher" (dict "@type" "Organization" "name" site.Title "logo" (dict "@type" "ImageObject" "url" $logo)) +-}} +{{- with $desc -}} + {{- $ld = merge $ld (dict "description" .) -}} +{{- end -}} +{{- if not .Date.IsZero -}} + {{- $ld = merge $ld (dict "datePublished" (.Date.Format "2006-01-02T15:04:05Z07:00")) -}} +{{- end -}} +{{- if not .Lastmod.IsZero -}} + {{- $ld = merge $ld (dict "dateModified" (.Lastmod.Format "2006-01-02T15:04:05Z07:00")) -}} +{{- end -}} +{{- with $socialImage -}} + {{- $ld = merge $ld (dict "image" (. | absURL)) -}} +{{- end -}} +{{- printf "" ($ld | jsonify (dict "indent" " ")) | safeHTML -}} +{{- end -}} diff --git a/layouts/_partials/schema-breadcrumb.html b/layouts/_partials/schema-breadcrumb.html new file mode 100644 index 000000000..654a33d38 --- /dev/null +++ b/layouts/_partials/schema-breadcrumb.html @@ -0,0 +1,32 @@ +{{- /* + schema-breadcrumb.html + Emits BreadcrumbList JSON-LD from the page hierarchy. + + Skipped on the home page (no trail) and on redirect stubs. The Docsy theme + already renders visible breadcrumbs; this adds the matching structured data. +*/ -}} +{{- if and (not .IsHome) (ne .Type "redirect") (ne .Kind "404") -}} +{{- $items := slice -}} +{{- $pos := 1 -}} +{{- range .Ancestors.Reverse -}} + {{- $items = $items | append (dict + "@type" "ListItem" + "position" $pos + "name" (.LinkTitle | default .Title) + "item" .Permalink + ) -}} + {{- $pos = add $pos 1 -}} +{{- end -}} +{{- $items = $items | append (dict + "@type" "ListItem" + "position" $pos + "name" (.LinkTitle | default .Title) + "item" .Permalink +) -}} +{{- $ld := dict + "@context" "https://schema.org" + "@type" "BreadcrumbList" + "itemListElement" $items +-}} +{{- printf "" ($ld | jsonify (dict "indent" " ")) | safeHTML -}} +{{- end -}} diff --git a/layouts/_partials/schema-faq.html b/layouts/_partials/schema-faq.html new file mode 100644 index 000000000..5ca27c0ef --- /dev/null +++ b/layouts/_partials/schema-faq.html @@ -0,0 +1,50 @@ +{{- /* + schema-faq.html + Emits FAQPage JSON-LD derived from the page's H2 sections. + + Opt-in per page via front matter: + schema: faq # single value + schema: ["faq", ...] # list form also accepted + + Each top-level

is treated as a Question and the HTML that follows it + (up to the next

) as the accepted Answer. HTML is reduced to plain text. +*/ -}} +{{- $schema := .Params.schema | default "" -}} +{{- $isFaq := false -}} +{{- if reflect.IsSlice $schema -}} + {{- if in $schema "faq" -}}{{- $isFaq = true -}}{{- end -}} +{{- else if eq $schema "faq" -}} + {{- $isFaq = true -}} +{{- end -}} +{{- if $isFaq -}} +{{- $qa := slice -}} +{{- $parts := split .Content "" -}} + {{- $head := index $sub 0 -}} + {{- $question := index (split $head ">") 1 | plainify | htmlUnescape | replaceRE `\s+` " " | strings.TrimSpace -}} + {{- $answer := "" -}} + {{- if gt (len $sub) 1 -}} + {{- $answer = index $sub 1 | plainify | htmlUnescape | replaceRE `\s+` " " | strings.TrimSpace -}} + {{- end -}} + {{- if and $question $answer -}} + {{- $qa = $qa | append (dict + "@type" "Question" + "name" $question + "acceptedAnswer" (dict "@type" "Answer" "text" $answer) + ) -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- if gt (len $qa) 0 -}} +{{- $ld := dict + "@context" "https://schema.org" + "@type" "FAQPage" + "url" .Permalink + "inLanguage" "en-US" + "mainEntity" $qa +-}} +{{- printf "" ($ld | jsonify (dict "indent" " ")) | safeHTML -}} +{{- end -}} +{{- end -}} diff --git a/layouts/_partials/schema-organization.html b/layouts/_partials/schema-organization.html new file mode 100644 index 000000000..21aabd4f2 --- /dev/null +++ b/layouts/_partials/schema-organization.html @@ -0,0 +1,35 @@ +{{- /* + schema-organization.html + Emits Organization JSON-LD for the site. Rendered on the home page only. + + Profile URLs for sameAs are gathered from the footer link lists in + config/_default/params.yaml (`links.user` and `links.developer`), keeping only + absolute http(s) URLs (mailto: and relative RSS links are skipped). +*/ -}} +{{- if .IsHome -}} +{{- $logo := "Resources/logo_red_no_border_568x385.png" | absURL -}} +{{- $sameAs := slice -}} +{{- range site.Params.links.user -}} + {{- with .url -}}{{ if hasPrefix . "http" }}{{ $sameAs = $sameAs | append . }}{{ end }}{{- end -}} +{{- end -}} +{{- range site.Params.links.developer -}} + {{- with .url -}}{{ if hasPrefix . "http" }}{{ $sameAs = $sameAs | append . }}{{ end }}{{- end -}} +{{- end -}} +{{- $org := dict + "@context" "https://schema.org" + "@type" "Organization" + "name" site.Title + "url" site.Home.Permalink + "logo" $logo +-}} +{{- with site.Params.description -}} + {{- $org = merge $org (dict "description" .) -}} +{{- end -}} +{{- with site.Params.author -}} + {{- with .email -}}{{- $org = merge $org (dict "email" .) -}}{{- end -}} +{{- end -}} +{{- if gt (len $sameAs) 0 -}} + {{- $org = merge $org (dict "sameAs" $sameAs) -}} +{{- end -}} +{{- printf "" ($org | jsonify (dict "indent" " ")) | safeHTML -}} +{{- end -}} diff --git a/tests/test_site_jsonld.py b/tests/test_site_jsonld.py new file mode 100644 index 000000000..a30dec867 --- /dev/null +++ b/tests/test_site_jsonld.py @@ -0,0 +1,379 @@ +""" +tests/test_site_jsonld.py +========================= +Validates the site-wide JSON-LD structured data added in Phase 1.2 of +``docs/SEO_IMPROVEMENT_PLAN.md``: + + Organization → home page (layouts/_partials/schema-organization.html) + Article → regular content pages (layouts/_partials/schema-article.html) + FAQPage → pages with `schema: faq` front matter + (layouts/_partials/schema-faq.html) + BreadcrumbList→ every non-home page (layouts/_partials/schema-breadcrumb.html) + +These complement ``test_bibliography_jsonld.py``, which covers the +type-specific schema emitted for bibliography pages. In particular, this +module asserts that regular pages do **not** collide with the bibliography +schema (no generic ``Article`` on bibliography entries). + +Design +------ +The session-scoped, autouse ``hugo_build`` fixture (conftest.py) builds the +*testing* environment into ``tests/public_test/``. That environment mounts the +real ``content/en`` tree alongside the ``tests/fixtures/bibliography`` fixtures, +so a single build exercises both real content pages and bibliography fixtures. +(It is the CI-reliable build: the generated ``content/en/history/bibliography/**`` +entries are gitignored and absent in CI, but the ZTEST fixtures are always +present.) + +Usage +----- + pytest tests/test_site_jsonld.py -v + PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_site_jsonld.py -v +""" + +import html as html_mod +import json +import re +from html.parser import HTMLParser +from pathlib import Path + +import pytest +import yaml + +from conftest import REPO_ROOT, TEST_PUBLIC + +# --------------------------------------------------------------------------- +# HTML extraction helpers +# --------------------------------------------------------------------------- + + +class _JSONLDExtractor(HTMLParser): + """Collect the raw text of every ``application/ld+json`` script element.""" + + def __init__(self) -> None: + super().__init__() + self._in_jsonld = False + self._buf: list[str] = [] + self.blocks: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list) -> None: + if tag == "script" and dict(attrs).get("type") == "application/ld+json": + self._in_jsonld = True + self._buf = [] + + def handle_endtag(self, tag: str) -> None: + if tag == "script" and self._in_jsonld: + self._in_jsonld = False + self.blocks.append("".join(self._buf)) + + def handle_data(self, data: str) -> None: + if self._in_jsonld: + self._buf.append(data) + + +def _jsonld_blocks(path: Path) -> list[dict]: + """Return every parseable JSON-LD object in *path*. + + Skips (does not fail) when the built page is absent so collection errors + clearly point at the missing file. + """ + if not path.exists(): + pytest.skip( + f"Built page not found: {path}\n" + "Run 'hugo --environment testing --destination tests/public_test' " + "from the repo root, then re-run the tests." + ) + parser = _JSONLDExtractor() + parser.feed(path.read_text(encoding="utf-8")) + out: list[dict] = [] + for raw in parser.blocks: + try: + data = json.loads(raw.strip()) + except json.JSONDecodeError: + continue + if isinstance(data, dict): + out.append(data) + return out + + +def _find(path: Path, type_filter: str) -> dict: + """Return the first JSON-LD object with ``@type == type_filter``.""" + for data in _jsonld_blocks(path): + if data.get("@type") == type_filter: + return data + pytest.fail(f"No {type_filter!r} JSON-LD block found in {path}") + + +def _all_pages() -> list[Path]: + """Every built ``index.html`` in the testing output (plus 404.html).""" + pages = sorted(TEST_PUBLIC.rglob("index.html")) + not_found = TEST_PUBLIC / "404.html" + if not_found.exists(): + pages.append(not_found) + return pages + + +def _site_config() -> dict: + cfg = REPO_ROOT / "config" / "_default" / "hugo.yaml" + return yaml.safe_load(cfg.read_text(encoding="utf-8")) or {} + + +def _base_url() -> str: + cfg = REPO_ROOT / "config" / "testing" / "hugo.yaml" + return (yaml.safe_load(cfg.read_text(encoding="utf-8")) or {}).get("baseURL", "") + + +HOME = TEST_PUBLIC / "index.html" +FAQS = TEST_PUBLIC / "project" / "faqs" / "index.html" +PRIVACY = TEST_PUBLIC / "privacy" / "index.html" +NESTED = ( + TEST_PUBLIC / "software" / "install-and-run" / "linux" + / "linux-standard-from-github" / "index.html" +) +BIB_ENTRY = TEST_PUBLIC / "history" / "bibliography" / "ztest-journal" / "index.html" + + +# =========================================================================== +# Organization +# =========================================================================== + + +class TestOrganization: + """The home page carries a single Organization block.""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _find(HOME, "Organization") + + def test_context_and_type(self): + assert self.ld["@context"] == "https://schema.org" + assert self.ld["@type"] == "Organization" + + def test_name_matches_site_title(self): + assert self.ld["name"] == _site_config().get("title") + + def test_url_is_home(self): + assert self.ld["url"].endswith("/") + + def test_logo_is_absolute_image(self): + logo = self.ld["logo"] + assert logo.startswith("http") + assert logo.lower().endswith(".png") + + def test_same_as_includes_social_profiles(self): + same_as = set(self.ld.get("sameAs", [])) + for expected in ( + "https://fosstodon.org/@interlisp", + "https://bsky.app/profile/interlisp.org", + "https://www.youtube.com/@Interlisp", + "https://github.com/interlisp", + ): + assert expected in same_as, f"sameAs missing {expected!r}" + + def test_same_as_excludes_non_http(self): + for url in self.ld.get("sameAs", []): + assert url.startswith("http"), f"non-http profile in sameAs: {url!r}" + + def test_home_has_no_article(self): + assert not any( + d.get("@type") == "Article" for d in _jsonld_blocks(HOME) + ), "Home page must not emit an Article block" + + +# =========================================================================== +# Article +# =========================================================================== + + +class TestArticle: + """Regular content pages emit an Article block.""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _find(PRIVACY, "Article") + + def test_context_and_type(self): + assert self.ld["@context"] == "https://schema.org" + assert self.ld["@type"] == "Article" + + def test_headline_present(self): + assert self.ld.get("headline", "").strip() + + def test_url_matches_page(self): + assert self.ld["url"].endswith("/privacy/") + + def test_author_is_organization(self): + assert self.ld["author"]["@type"] == "Organization" + assert self.ld["author"]["name"] == "Interlisp.org" + + def test_publisher_is_organization_with_logo(self): + pub = self.ld["publisher"] + assert pub["@type"] == "Organization" + assert pub["logo"]["@type"] == "ImageObject" + assert pub["logo"]["url"].startswith("http") + + def test_main_entity_points_at_page(self): + meop = self.ld["mainEntityOfPage"] + assert meop["@type"] == "WebPage" + assert meop["@id"] == self.ld["url"] + + def test_dates_are_iso(self): + for key in ("datePublished", "dateModified"): + assert key in self.ld, f"Article missing {key}" + assert re.match(r"\d{4}-\d{2}-\d{2}T", self.ld[key]), self.ld[key] + + def test_image_is_absolute(self): + assert self.ld["image"].startswith("http") + + def test_section_pages_have_no_article(self): + """Section/list pages (e.g. /software/) are not articles.""" + section = TEST_PUBLIC / "software" / "index.html" + assert not any( + d.get("@type") == "Article" for d in _jsonld_blocks(section) + ), "Section page must not emit an Article block" + + +# =========================================================================== +# FAQPage +# =========================================================================== + + +class TestFaqPage: + """Pages opted in with `schema: faq` emit a FAQPage block.""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _find(FAQS, "FAQPage") + + def test_context_and_type(self): + assert self.ld["@context"] == "https://schema.org" + assert self.ld["@type"] == "FAQPage" + + def test_has_questions(self): + assert len(self.ld["mainEntity"]) >= 1 + + def test_each_entry_is_question_with_answer(self): + for q in self.ld["mainEntity"]: + assert q["@type"] == "Question" + assert q["name"].strip() + ans = q["acceptedAnswer"] + assert ans["@type"] == "Answer" + assert ans["text"].strip() + + def test_questions_match_h2_headings(self): + """Each H2 on the page becomes one question, in document order.""" + html = FAQS.read_text(encoding="utf-8") + headings = [ + re.sub(r"\s+", " ", html_mod.unescape(re.sub(r"<[^>]+>", "", h))).strip() + for h in re.findall(r"]*>(.*?)

", html, re.S) + ] + names = [q["name"] for q in self.ld["mainEntity"]] + assert names == headings + + def test_no_html_entities_in_text(self): + """plainify + htmlUnescape must leave no raw entities behind.""" + blob = json.dumps(self.ld) + assert not re.search(r"&[a-zA-Z]+;", blob), ( + "HTML entity leaked into FAQPage text" + ) + + +# =========================================================================== +# BreadcrumbList +# =========================================================================== + + +class TestBreadcrumb: + """Every non-home page emits a BreadcrumbList rooted at the home page.""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _find(NESTED, "BreadcrumbList") + + def test_context_and_type(self): + assert self.ld["@context"] == "https://schema.org" + assert self.ld["@type"] == "BreadcrumbList" + + def test_positions_are_sequential(self): + items = self.ld["itemListElement"] + assert [i["position"] for i in items] == list(range(1, len(items) + 1)) + + def test_first_item_is_home(self): + items = self.ld["itemListElement"] + assert items[0]["item"] == _base_url() + assert items[0]["name"] + + def test_last_item_is_current_page(self): + items = self.ld["itemListElement"] + assert items[-1]["item"].endswith("/linux-standard-from-github/") + + def test_home_page_has_no_breadcrumb(self): + assert not any( + d.get("@type") == "BreadcrumbList" for d in _jsonld_blocks(HOME) + ), "Home page must not emit a BreadcrumbList" + + +# =========================================================================== +# Cross-cutting invariants +# =========================================================================== + + +def test_organization_only_on_home() -> None: + """Organization appears on the home page and nowhere else.""" + for page in _all_pages(): + has_org = any(d.get("@type") == "Organization" for d in _jsonld_blocks(page)) + if page == HOME: + assert has_org, "Home page is missing its Organization block" + else: + assert not has_org, f"Organization block unexpectedly on {page}" + + +def test_faqpage_only_where_opted_in() -> None: + """FAQPage appears only on pages carrying `schema: faq` front matter.""" + content_root = REPO_ROOT / "content" / "en" + opted_in: set[str] = set() + for md in content_root.rglob("*.md"): + text = md.read_text(encoding="utf-8") + front = text.split("---", 2) + if len(front) >= 3 and re.search(r"^schema:\s*(faq|\['?faq)", front[1], re.M): + rel = md.relative_to(content_root) + slug = rel.parent / rel.stem if rel.name == "_index.md" else rel.with_suffix("") + opted_in.add(str(slug).replace("\\", "/")) + + for page in _all_pages(): + has_faq = any(d.get("@type") == "FAQPage" for d in _jsonld_blocks(page)) + if has_faq: + rel = str(page.parent.relative_to(TEST_PUBLIC)).replace("\\", "/") + rel = "" if rel == "." else rel + assert rel in opted_in, ( + f"FAQPage emitted on {page} but its source has no `schema: faq`" + ) + + +def test_no_duplicate_structured_data_blocks() -> None: + """No page emits more than one of the singleton schema types.""" + singletons = ("Organization", "FAQPage", "BreadcrumbList") + problems: list[str] = [] + for page in _all_pages(): + blocks = _jsonld_blocks(page) + for stype in singletons: + count = sum(1 for d in blocks if d.get("@type") == stype) + if count > 1: + problems.append(f"{page}: {count}× {stype}") + assert not problems, "Duplicate JSON-LD blocks:\n" + "\n".join(problems) + + +def test_bibliography_pages_have_no_generic_article() -> None: + """Bibliography pages must use their own schema, not the generic Article.""" + blocks = _jsonld_blocks(BIB_ENTRY) + generic = [d for d in blocks if d.get("@type") == "Article" and "headline" in d] + assert not generic, ( + "Bibliography entry emitted a generic Article block (headline present) " + "in addition to its type-specific schema" + ) + # The bibliography type-specific schema should still be present. + assert any( + d.get("@type") == "ScholarlyArticle" for d in blocks + ), "Bibliography entry is missing its ScholarlyArticle schema" + # Breadcrumbs apply to bibliography pages too. + assert any(d.get("@type") == "BreadcrumbList" for d in blocks) From 6552c4c2d34220974f8b5be94b1e4e6304ea1500 Mon Sep 17 00:00:00 2001 From: Bill Stumbo Date: Thu, 12 Nov 2026 13:12:23 -0500 Subject: [PATCH 3/4] Continue with the SEO Optimization work. This update adds a metadata description for each page. This description is folded into the jsonld now being generated and attached to each page for seach engines. In addition, docsy uses the description and adds it at the top of each page. I've left that functionality intact. I haven't reviewed each page to validate that the description and the page text flow together appropriately... --- README.md | 32 +-- content/en/_index.md | 1 + content/en/history/_index.md | 1 + .../en/history/bibliography/intake/_index.md | 1 + .../bibliography/zotero-resources/_index.md | 2 +- content/en/history/glossary/_index.md | 1 + content/en/history/in-memoriam.md | 1 + content/en/history/links.md | 1 + content/en/history/timeline/_index.md | 1 + content/en/privacy.md | 1 + content/en/project/_index.md | 1 + content/en/project/comments/_index.md | 2 +- content/en/project/credits.md | 1 + content/en/project/donate.md | 1 + content/en/project/faqs.md | 2 +- content/en/project/getInvolved/_index.md | 1 + content/en/project/organization.md | 1 + content/en/project/partners/SHFT.md | 1 + content/en/project/partners/SPN.md | 1 + content/en/project/partners/_index.md | 1 + content/en/project/partners/educopia.md | 1 + .../en/project/partners/interlisporg-inc.md | 1 + content/en/project/reviving.md | 1 + content/en/project/status/_index.md | 1 + .../status/reports/2021MedleyAnnualReport.md | 1 + .../status/reports/2022MedleyAnnualReport.md | 1 + .../status/reports/2023MedleyAnnualReport.md | 1 + .../status/reports/2024MedleyAnnualReport.md | 1 + .../status/reports/2025MedleyAnnualReport.md | 1 + content/en/project/status/reports/_index.md | 1 + content/en/project/stories.md | 1 + content/en/search.md | 1 + content/en/software/_index.md | 1 + content/en/software/access-online/_index.md | 1 + content/en/software/install-and-run/_index.md | 3 +- .../software/install-and-run/linux/_index.md | 3 +- .../linux/linux-local-from-github.md | 3 +- .../linux/linux-standard-from-github.md | 3 +- .../software/install-and-run/macos/_index.md | 3 +- .../macos/macos-from-github.md | 3 +- .../install-and-run/windows/_index.md | 1 + .../install-and-run/windows/native/_index.md | 3 +- .../native/windows-native-from-github.md | 3 +- .../install-and-run/windows/wsl/_index.md | 3 +- .../wsl/windows-wsl-local-from-github.md | 3 +- .../wsl/windows-wsl-standard-from-github.md | 3 +- content/en/software/screenshots/_index.md | 1 + content/en/software/using-medley/_index.md | 1 + content/en/software/using-medley/cl-using.md | 1 + content/en/software/using-medley/il-using.md | 1 + .../en/software/using-medley/keystrokes.md | 1 + .../en/software/using-medley/online-using.md | 1 + layouts/_partials/hooks/head-end.html | 1 - layouts/_partials/page-description.html | 54 +++-- layouts/_partials/schema-faq.html | 50 ----- tests/test_meta_descriptions.py | 199 ++++++++++++++++++ tests/test_site_jsonld.py | 73 +------ 57 files changed, 323 insertions(+), 162 deletions(-) delete mode 100644 layouts/_partials/schema-faq.html create mode 100644 tests/test_meta_descriptions.py diff --git a/README.md b/README.md index f3df9af10..c93f36c0d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Interlisp and how to access and use the restored Medley system. - [Adding New Pages](#adding-new-pages) - [Social Share Images and Metadata](#social-share-images-and-metadata) - [Structured Data (JSON-LD)](#structured-data-json-ld) + - [Meta Descriptions](#meta-descriptions) - [Adding or Updating the Comments Page](#adding-or-updating-the-comments-page) - [Bibliography Data](#bibliography-data) - [Local Development](#local-development) @@ -145,34 +146,39 @@ Bluesky and Mastodon build their previews from Open Graph tags only (they ignore ### Structured Data (JSON-LD) -The site emits [schema.org](https://schema.org) JSON-LD so search engines can show rich results (article cards, breadcrumbs, FAQ accordions). The partials live in `layouts/_partials/` and are wired into `layouts/_partials/hooks/head-end.html`; each one guards itself, so authors normally don't need to do anything. +The site emits [schema.org](https://schema.org) JSON-LD so search engines can show rich results (article cards, breadcrumbs). The partials live in `layouts/_partials/` and are wired into `layouts/_partials/hooks/head-end.html`; each one guards itself, so authors normally don't need to do anything. | Schema | Where it appears | Source partial | |--------|------------------|----------------| | `Organization` | Home page only | `schema-organization.html` | | `Article` | Every content page (not bibliography entries or sections) | `schema-article.html` | | `BreadcrumbList` | Every page except the home page and 404 | `schema-breadcrumb.html` | -| `FAQPage` | Pages opted in with `schema: faq` | `schema-faq.html` | | Bibliography types | `content/en/history/bibliography/**` | `bibliography-json-ld.html` | - **Article metadata** is derived automatically: `headline` from the title, `description` from the page description, `datePublished` from `date`, `dateModified` from the file's last git change (`enableGitInfo: true`), and `author`/`publisher` from `site.Params.author`. -- **To mark a page as an FAQ**, add `schema: faq` to its front matter. Each `##` (H2) heading becomes a question and the text beneath it becomes the answer: +- **Validate locally** with the test suite (`tests/test_site_jsonld.py`) and, before release, with Google's [Rich Results Test](https://search.google.com/test/rich-results). + +### Meta Descriptions + +The `` tag is generated for every page by `layouts/_partials/page-description.html`. It is what search engines may show beneath a page title in results, so it should be a clear, compelling summary that includes the page's main topic. + +**Authoring rules:** + +- Add a `description:` field to a page's front matter. Aim for **150–160 characters** (about one to two sentences). +- Make it **unique** — avoid reusing the same text across pages. +- Describe what the reader will find and include the primary keyword naturally. Do not stuff keywords or simply repeat the title. ```markdown --- - title: FAQs - weight: 20 - type: docs - schema: faq + title: History + description: "Explore the history of Interlisp, the pioneering Lisp software development environment born at Xerox PARC in the 1970s and 1980s." --- - - ## Is copy and paste supported? - - Yes. Medley supports copying text between the Interlisp environment and your host system. ``` - Only use this on genuine question-and-answer pages; search engines may penalize incorrect FAQ markup. -- **Validate locally** with the test suite (`tests/test_site_jsonld.py`) and, before release, with Google's [Rich Results Test](https://search.google.com/test/rich-results). +**Fallback behavior** (when a page has no `description:`): the partial uses, in order, the page's `description`, then its auto-generated summary, then a generated description (for bibliography, taxonomy, and term pages), and finally the site-wide default from `config/_default/params.yaml`. Any description longer than 160 characters is truncated at a word boundary. Relying on the fallback is fine for minor pages, but hand-written descriptions are strongly preferred for anything in the navigation. + +- **Bibliography entries** are described automatically from the entry's title and abstract (or authors); you do not need to add `description:` to generated entries. Hand-written pages that merely live under the bibliography tree (e.g. `intake/`, `zotero-resources/`, which set `type: docs`) are *not* generated entries and still need their own `description:`; the test identifies generated entries from the rendered `class="bibliography-entry"` marker rather than the URL. +- **Validate locally** with `tests/test_meta_descriptions.py`. ### Adding or Updating the Comments Page diff --git a/content/en/_index.md b/content/en/_index.md index 4cd6206aa..d6f6f8087 100644 --- a/content/en/_index.md +++ b/content/en/_index.md @@ -1,5 +1,6 @@ --- title: Medley Interlisp Project +description: "Reviving Interlisp-D, the pioneering Lisp software environment created at Xerox PARC. Explore Medley's history, documentation, and how to run it today." aliases: - /hugo --- diff --git a/content/en/history/_index.md b/content/en/history/_index.md index 71626f8c9..afa5e6bb6 100644 --- a/content/en/history/_index.md +++ b/content/en/history/_index.md @@ -1,5 +1,6 @@ --- title: History +description: "Trace how Interlisp evolved from an interactive programming tool into Interlisp-D and Medley, and the research fields it helped pioneer, from AI to GUIs." weight: 500 type: docs aliases: diff --git a/content/en/history/bibliography/intake/_index.md b/content/en/history/bibliography/intake/_index.md index 0c0843ece..b4bf37915 100644 --- a/content/en/history/bibliography/intake/_index.md +++ b/content/en/history/bibliography/intake/_index.md @@ -1,5 +1,6 @@ --- title: Interlisp Bibliography Intake Guide +description: "Not sure whether a document belongs in the Interlisp Bibliography? This guide explains the collection's scope, how to submit an item, and what to include." weight: 1 type: docs # Explicitly visible in the sidebar: the parent bibliography section's diff --git a/content/en/history/bibliography/zotero-resources/_index.md b/content/en/history/bibliography/zotero-resources/_index.md index 2475e3fb2..93790cad9 100644 --- a/content/en/history/bibliography/zotero-resources/_index.md +++ b/content/en/history/bibliography/zotero-resources/_index.md @@ -1,6 +1,6 @@ --- title: "Zotero Type Mapping Reference" -description: "How Interlisp Zotero bibliography types map to CSL JSON and Schema.org vocabulary" +description: "For Interlisp Zotero contributors: how each bibliography item type is classified and mapped through the processing pipeline to CSL JSON and Schema.org." type: docs toc_hide: false --- diff --git a/content/en/history/glossary/_index.md b/content/en/history/glossary/_index.md index 69c3cbf32..76eff87d8 100644 --- a/content/en/history/glossary/_index.md +++ b/content/en/history/glossary/_index.md @@ -1,5 +1,6 @@ --- title: Glossary +description: "Confused by the many names used for Interlisp over the decades? This glossary untangles Medley, Interlisp-D, and related terms with clear definitions." aliases: - /glossary/ - /medley/project/vocabulary/ diff --git a/content/en/history/in-memoriam.md b/content/en/history/in-memoriam.md index eb96274ef..f826eb435 100644 --- a/content/en/history/in-memoriam.md +++ b/content/en/history/in-memoriam.md @@ -1,5 +1,6 @@ --- title: In Memoriam +description: "A tribute to the people whose work shaped Interlisp, remembering their contributions to the language, its development tools, and the community around it." weight: 100 type: docs --- diff --git a/content/en/history/links.md b/content/en/history/links.md index 067a2c2f4..5011bc096 100644 --- a/content/en/history/links.md +++ b/content/en/history/links.md @@ -1,5 +1,6 @@ --- title: Interlisp History Resources +description: "Where to find Interlisp artifacts online: a curated set of archives, collections, and computing-history resources the Medley team draws on for research." weight: 95 type: docs --- diff --git a/content/en/history/timeline/_index.md b/content/en/history/timeline/_index.md index 5db694e70..1587ec107 100644 --- a/content/en/history/timeline/_index.md +++ b/content/en/history/timeline/_index.md @@ -1,5 +1,6 @@ --- title: Interlisp Timeline +description: "Follow the milestones of Interlisp's development year by year, from its earliest experiments through the rise of Interlisp-D and the Medley era, and beyond." type: docs weight: 50 aliases: diff --git a/content/en/privacy.md b/content/en/privacy.md index 6382a7343..5ca39145a 100644 --- a/content/en/privacy.md +++ b/content/en/privacy.md @@ -1,5 +1,6 @@ --- title: Interlisp.org Privacy Statement +description: "What data Interlisp.org collects and why, how cookies and Google Analytics are used, and the choices visitors have about their information and privacy." url: /privacy/ type: docs weight: 30 diff --git a/content/en/project/_index.md b/content/en/project/_index.md index 9b1e559ed..c8f584d39 100644 --- a/content/en/project/_index.md +++ b/content/en/project/_index.md @@ -1,5 +1,6 @@ --- Title: The Medley Interlisp Project +description: "Why we are bringing Medley back to life, what we hope to accomplish, and the state of the work: an introduction to the project and its ambitions for the future." weight: 2 type: docs aliases: diff --git a/content/en/project/comments/_index.md b/content/en/project/comments/_index.md index fd6f875a4..e49f4141e 100644 --- a/content/en/project/comments/_index.md +++ b/content/en/project/comments/_index.md @@ -2,7 +2,7 @@ title: What people are saying weight: 20 type: docs -description: Comments on Interlisp and this project +description: "How people talk about Interlisp online: a running collection of blog posts, social media threads, and reflections, with a way to submit new links of your own." aliases: - /medley/about/comments/ --- diff --git a/content/en/project/credits.md b/content/en/project/credits.md index c3122eaf9..9a50ea445 100644 --- a/content/en/project/credits.md +++ b/content/en/project/credits.md @@ -1,5 +1,6 @@ --- title: Credits +description: "Acknowledging the many people who built Interlisp over the decades, and the challenge of crediting work that spanned several companies across many years." weight: 80 type: docs --- diff --git a/content/en/project/donate.md b/content/en/project/donate.md index bb64af662..4e485d882 100644 --- a/content/en/project/donate.md +++ b/content/en/project/donate.md @@ -1,5 +1,6 @@ --- title: Donate +description: "Help sustain the Interlisp revival. See why your gift matters, what it funds, and the different ways you can contribute to keeping Medley available to everyone." weight: 15 type: docs --- diff --git a/content/en/project/faqs.md b/content/en/project/faqs.md index 059152e41..787431f81 100644 --- a/content/en/project/faqs.md +++ b/content/en/project/faqs.md @@ -1,8 +1,8 @@ --- title: FAQs +description: "Straightforward answers to the questions people ask most often when getting started with Medley Interlisp or working in it day to day, from editors to printing." weight: 20 type: docs -schema: faq --- ## Is copy and paste available? diff --git a/content/en/project/getInvolved/_index.md b/content/en/project/getInvolved/_index.md index 10802cfb5..a784df213 100644 --- a/content/en/project/getInvolved/_index.md +++ b/content/en/project/getInvolved/_index.md @@ -1,5 +1,6 @@ --- title: Get Involved +description: "There is a place for every skill level in the Interlisp revival. Find the route that fits you, whether you code, test, document, or help spread the word." weight: 11 type: docs aliases: diff --git a/content/en/project/organization.md b/content/en/project/organization.md index d62239f28..ec882fa3e 100644 --- a/content/en/project/organization.md +++ b/content/en/project/organization.md @@ -1,5 +1,6 @@ --- title: Organization +description: "Who runs the project and how it is structured, including the non-profit that holds its charitable mission and supports its scientific and technical goals." weight: 1000 type: docs --- diff --git a/content/en/project/partners/SHFT.md b/content/en/project/partners/SHFT.md index b9babe88c..f22adb777 100644 --- a/content/en/project/partners/SHFT.md +++ b/content/en/project/partners/SHFT.md @@ -1,5 +1,6 @@ --- title: Software History Futures and Technologies SHFT +description: "A University of Alberta research group studying software history partners with us to make Interlisp's innovations and context easier to explore and understand." weight: 20 type: docs --- diff --git a/content/en/project/partners/SPN.md b/content/en/project/partners/SPN.md index 21d547b92..5c3a331ff 100644 --- a/content/en/project/partners/SPN.md +++ b/content/en/project/partners/SPN.md @@ -1,5 +1,6 @@ --- title: Software Preservation Network +description: "Why we joined a network of libraries, archives, and universities working to keep software usable for the long term, and what that partnership involves." weight: 20 type: docs --- diff --git a/content/en/project/partners/_index.md b/content/en/project/partners/_index.md index a753bbcc7..911ac6212 100644 --- a/content/en/project/partners/_index.md +++ b/content/en/project/partners/_index.md @@ -1,5 +1,6 @@ --- title: Organizational Partners +description: "The organizations and academic groups collaborating with the Medley Interlisp Project, and how these partnerships advance software preservation and research." weight: 18 type: docs aliases: diff --git a/content/en/project/partners/educopia.md b/content/en/project/partners/educopia.md index 2fe21d97b..8efab6631 100644 --- a/content/en/project/partners/educopia.md +++ b/content/en/project/partners/educopia.md @@ -1,5 +1,6 @@ --- title: Educopia +description: "The non-profit behind the Software Preservation Network, and the governance and community-building resources it offers to preservation projects and communities." weight: 20 type: docs aliases: diff --git a/content/en/project/partners/interlisporg-inc.md b/content/en/project/partners/interlisporg-inc.md index 07d16abf5..8383fded3 100644 --- a/content/en/project/partners/interlisporg-inc.md +++ b/content/en/project/partners/interlisporg-inc.md @@ -1,5 +1,6 @@ --- title: InterlispOrg +description: "The non-profit corporation that funds and steers the revival, including its tax-exempt status, how it is governed, and how to support it financially today." weight: 25 type: docs aliases: diff --git a/content/en/project/reviving.md b/content/en/project/reviving.md index 95213b195..957377bb9 100644 --- a/content/en/project/reviving.md +++ b/content/en/project/reviving.md @@ -1,5 +1,6 @@ --- Title: Reviving Medley +description: "The technical decisions behind the revival: what it takes to bring a decades-old system forward, and the areas of work the project is tackling now and next." weight: 80 type: docs --- diff --git a/content/en/project/status/_index.md b/content/en/project/status/_index.md index cf2f7ca77..cedd2fa27 100644 --- a/content/en/project/status/_index.md +++ b/content/en/project/status/_index.md @@ -1,5 +1,6 @@ --- title: News and Status Reports +description: "The latest news from the project — reports, interviews, and announcements — with an archive of how the revival has progressed year by year since 2020." type: docs weight: 2 outputs: diff --git a/content/en/project/status/reports/2021MedleyAnnualReport.md b/content/en/project/status/reports/2021MedleyAnnualReport.md index 31e508548..d541f682c 100644 --- a/content/en/project/status/reports/2021MedleyAnnualReport.md +++ b/content/en/project/status/reports/2021MedleyAnnualReport.md @@ -1,5 +1,6 @@ --- title: 2021 Medley Interlisp Annual Report +description: "The report on the project's founding year: early decisions, initial milestones, and the groundwork laid for restoring Medley and making it usable again." weight: 10 type: docs aliases: diff --git a/content/en/project/status/reports/2022MedleyAnnualReport.md b/content/en/project/status/reports/2022MedleyAnnualReport.md index 5d7d19792..ec151759b 100644 --- a/content/en/project/status/reports/2022MedleyAnnualReport.md +++ b/content/en/project/status/reports/2022MedleyAnnualReport.md @@ -1,5 +1,6 @@ --- title: 2022 Medley Interlisp Annual Report +description: "A year of milestones: what the team accomplished in 2022, the collaborations it formed, and how the preservation work advanced over the course of the year." weight: 9 type: docs aliases: diff --git a/content/en/project/status/reports/2023MedleyAnnualReport.md b/content/en/project/status/reports/2023MedleyAnnualReport.md index fca9360dd..d51e07359 100644 --- a/content/en/project/status/reports/2023MedleyAnnualReport.md +++ b/content/en/project/status/reports/2023MedleyAnnualReport.md @@ -1,5 +1,6 @@ --- title: 2023 Annual Report +description: "The 2023 report details the year's achievements and the continuing effort to keep Interlisp usable, documented, and available to new audiences around the world." weight: 8 type: docs aliases: diff --git a/content/en/project/status/reports/2024MedleyAnnualReport.md b/content/en/project/status/reports/2024MedleyAnnualReport.md index 492545f3a..0c83f9306 100755 --- a/content/en/project/status/reports/2024MedleyAnnualReport.md +++ b/content/en/project/status/reports/2024MedleyAnnualReport.md @@ -1,5 +1,6 @@ --- title: 2024 Medley Interlisp Annual Report +description: "The 2024 report covers another year of porting, documenting, and improving Medley, along with the project's goals and priorities for the year ahead of us." weight: 7 type: docs aliases: diff --git a/content/en/project/status/reports/2025MedleyAnnualReport.md b/content/en/project/status/reports/2025MedleyAnnualReport.md index 2ed7020eb..18216abd0 100644 --- a/content/en/project/status/reports/2025MedleyAnnualReport.md +++ b/content/en/project/status/reports/2025MedleyAnnualReport.md @@ -1,5 +1,6 @@ --- title: 2025 Medley Interlisp Annual Report +description: "Five years in: the 2025 report looks back at how far the revival has come and the work still ahead to keep Medley thriving on modern systems and gaining users." weight: 1 type: docs aliases: diff --git a/content/en/project/status/reports/_index.md b/content/en/project/status/reports/_index.md index b3c67e529..48dd103a0 100644 --- a/content/en/project/status/reports/_index.md +++ b/content/en/project/status/reports/_index.md @@ -1,5 +1,6 @@ --- title: Annual Reports +description: "Browse every annual report in one place and follow the project's progress from its first year through the present day, with a link to each year's report." weight: 1 type: docs --- diff --git a/content/en/project/stories.md b/content/en/project/stories.md index 2e608f3dd..abdfefbc9 100644 --- a/content/en/project/stories.md +++ b/content/en/project/stories.md @@ -1,5 +1,6 @@ --- title: Our Stories +description: "First-hand accounts from people who used Interlisp in its prime, recalling what made the environment special and why it still matters to them and to computing." weight: 40 type: docs --- diff --git a/content/en/search.md b/content/en/search.md index f03f13445..df6698621 100644 --- a/content/en/search.md +++ b/content/en/search.md @@ -1,4 +1,5 @@ +++ title = "Search Results" +description = "Search Interlisp.org to find documentation, history, software guides, and bibliography entries about Medley Interlisp and the historic Interlisp-D system." layout = "search" +++ \ No newline at end of file diff --git a/content/en/software/_index.md b/content/en/software/_index.md index b9eb8f608..5041a0e56 100644 --- a/content/en/software/_index.md +++ b/content/en/software/_index.md @@ -1,5 +1,6 @@ --- title: Software +description: "Everything you need to start using Medley: choose between running it in your browser or installing it locally, then dig into the guides and documentation." type: docs aliases: diff --git a/content/en/software/access-online/_index.md b/content/en/software/access-online/_index.md index 6d17e906b..e8fbdb117 100644 --- a/content/en/software/access-online/_index.md +++ b/content/en/software/access-online/_index.md @@ -1,5 +1,6 @@ --- title: Access Medley Online +description: "Try Medley instantly without installing anything. Learn what the online environment is suited for and what to keep in mind while you experiment with it." weight: 10 type: docs aliases: diff --git a/content/en/software/install-and-run/_index.md b/content/en/software/install-and-run/_index.md index fe5449567..3ac748ca3 100644 --- a/content/en/software/install-and-run/_index.md +++ b/content/en/software/install-and-run/_index.md @@ -1,5 +1,6 @@ ---- +--- title: Install and Run +description: "Choose your platform and follow the guides to get Medley running on your own machine, with notes on supported systems and architectures, from x86_64 to ARM." aliases: - /medley/using/build/ - /medley/using/running/ diff --git a/content/en/software/install-and-run/linux/_index.md b/content/en/software/install-and-run/linux/_index.md index 0445e036a..6ca4f86a4 100644 --- a/content/en/software/install-and-run/linux/_index.md +++ b/content/en/software/install-and-run/linux/_index.md @@ -1,5 +1,6 @@ ---- +--- title: Install and Run on Linux +description: "Get Medley running on your Linux machine, including what the system requires and how to handle the most common distributions and desktop environments." linkTitle: Linux weight: 20 type: docs diff --git a/content/en/software/install-and-run/linux/linux-local-from-github.md b/content/en/software/install-and-run/linux/linux-local-from-github.md index 894b2c4ce..755e328b1 100644 --- a/content/en/software/install-and-run/linux/linux-local-from-github.md +++ b/content/en/software/install-and-run/linux/linux-local-from-github.md @@ -1,5 +1,6 @@ ---- +--- title: Download WSL (Local Installation) Install Files from Github +description: "Follow step-by-step instructions to download the local-installation Medley Interlisp release files from GitHub and install Medley on your Linux system." weight: 40 toc_hide: true hide_summary: true diff --git a/content/en/software/install-and-run/linux/linux-standard-from-github.md b/content/en/software/install-and-run/linux/linux-standard-from-github.md index 4dbbd2f2e..00a167dba 100644 --- a/content/en/software/install-and-run/linux/linux-standard-from-github.md +++ b/content/en/software/install-and-run/linux/linux-standard-from-github.md @@ -1,5 +1,6 @@ ---- +--- title: Download WSL (Standard Installation) Install Files from Github +description: "Follow step-by-step instructions to download the standard Medley Interlisp release files from GitHub and install Medley on your most recent Linux system." weight: 40 toc_hide: true hide_summary: true diff --git a/content/en/software/install-and-run/macos/_index.md b/content/en/software/install-and-run/macos/_index.md index fe66c0005..3804fce93 100644 --- a/content/en/software/install-and-run/macos/_index.md +++ b/content/en/software/install-and-run/macos/_index.md @@ -1,5 +1,6 @@ ---- +--- title: Install and Run on MacOS +description: "Set up Medley on your Mac, including the X11 software you will need first and a pointer to the download steps, for both Apple silicon and Intel systems." linkTitle: MacOS weight: 30 type: docs diff --git a/content/en/software/install-and-run/macos/macos-from-github.md b/content/en/software/install-and-run/macos/macos-from-github.md index faf7f0c92..8dd86b69f 100644 --- a/content/en/software/install-and-run/macos/macos-from-github.md +++ b/content/en/software/install-and-run/macos/macos-from-github.md @@ -1,5 +1,6 @@ ---- +--- title: Download MacOS Install File from Github +description: "Follow the instructions to download the Medley Interlisp macOS install file from GitHub Releases and set up Medley on your Apple silicon or Intel Mac." weight: 40 toc_hide: true hide_summary: true diff --git a/content/en/software/install-and-run/windows/_index.md b/content/en/software/install-and-run/windows/_index.md index dcde3bfb8..8c2adfd7f 100644 --- a/content/en/software/install-and-run/windows/_index.md +++ b/content/en/software/install-and-run/windows/_index.md @@ -1,5 +1,6 @@ --- title: Install and Run on Windows +description: "Pick the Windows approach that suits you — a native install or one that runs inside Linux — and get Medley up and running either way, with a guide for each." linkTitle: Windows weight: 40 type: docs diff --git a/content/en/software/install-and-run/windows/native/_index.md b/content/en/software/install-and-run/windows/native/_index.md index f01f7963a..2dd88df5a 100644 --- a/content/en/software/install-and-run/windows/native/_index.md +++ b/content/en/software/install-and-run/windows/native/_index.md @@ -1,5 +1,6 @@ ---- +--- title: Install and Run on Windows "native" +description: "Run Medley directly on Windows without setting up a separate Linux environment, keeping everything in a self-contained folder that you completely control." linkTitle: native weight: 40 type: docs diff --git a/content/en/software/install-and-run/windows/native/windows-native-from-github.md b/content/en/software/install-and-run/windows/native/windows-native-from-github.md index 4b68b7a58..590256507 100644 --- a/content/en/software/install-and-run/windows/native/windows-native-from-github.md +++ b/content/en/software/install-and-run/windows/native/windows-native-from-github.md @@ -1,5 +1,6 @@ ---- +--- title: Download Windows 'native' Installer from Github +description: "Follow step-by-step instructions to download the Windows 'native' Medley installer from GitHub Releases and set up Medley on Windows without using WSL." weight: 40 toc_hide: true hide_summary: true diff --git a/content/en/software/install-and-run/windows/wsl/_index.md b/content/en/software/install-and-run/windows/wsl/_index.md index 71aacc90a..bd327f9ea 100644 --- a/content/en/software/install-and-run/windows/wsl/_index.md +++ b/content/en/software/install-and-run/windows/wsl/_index.md @@ -1,5 +1,6 @@ ---- +--- title: Install and Run on Windows with WSL +description: "Run Medley inside the Windows Subsystem for Linux, with guidance on choosing between WSL1 and WSL2 and notes on what each version supports and requires." linkTitle: WSL weight: 20 type: docs diff --git a/content/en/software/install-and-run/windows/wsl/windows-wsl-local-from-github.md b/content/en/software/install-and-run/windows/wsl/windows-wsl-local-from-github.md index 6fb7bffaf..5e5fd51de 100644 --- a/content/en/software/install-and-run/windows/wsl/windows-wsl-local-from-github.md +++ b/content/en/software/install-and-run/windows/wsl/windows-wsl-local-from-github.md @@ -1,5 +1,6 @@ ---- +--- title: Download WSL (Local Installation) Install Files from Github +description: "Follow the step-by-step instructions to download the WSL local-installation Medley Interlisp release files from GitHub and install Medley on Windows using WSL." weight: 40 toc_hide: true hide_summary: true diff --git a/content/en/software/install-and-run/windows/wsl/windows-wsl-standard-from-github.md b/content/en/software/install-and-run/windows/wsl/windows-wsl-standard-from-github.md index fac38b012..c68a38dbe 100644 --- a/content/en/software/install-and-run/windows/wsl/windows-wsl-standard-from-github.md +++ b/content/en/software/install-and-run/windows/wsl/windows-wsl-standard-from-github.md @@ -1,5 +1,6 @@ ---- +--- title: Download WSL (Standard Installation) Install Files from Github +description: "Follow the instructions to download the WSL standard Medley release files from GitHub and install Medley on Windows using the Windows Subsystem for Linux." weight: 40 toc_hide: true hide_summary: true diff --git a/content/en/software/screenshots/_index.md b/content/en/software/screenshots/_index.md index ef8f7c582..580d9c6e8 100644 --- a/content/en/software/screenshots/_index.md +++ b/content/en/software/screenshots/_index.md @@ -1,5 +1,6 @@ --- title: Gallery of Screenshots +description: "See what Medley looks like in practice through a collection of images showing its interfaces, built-in applications, and the ways users have customized it." type: docs weight: 90 --- diff --git a/content/en/software/using-medley/_index.md b/content/en/software/using-medley/_index.md index d19491066..65e93d9f4 100644 --- a/content/en/software/using-medley/_index.md +++ b/content/en/software/using-medley/_index.md @@ -1,5 +1,6 @@ --- title: Documentation +description: "Browse the manuals, guides, and reference material for working in Medley, whatever your starting point and level of experience with Interlisp or Lisp." weight: 20 type: docs aliases: diff --git a/content/en/software/using-medley/cl-using.md b/content/en/software/using-medley/cl-using.md index c5748c977..8f7e7d85c 100644 --- a/content/en/software/using-medley/cl-using.md +++ b/content/en/software/using-medley/cl-using.md @@ -1,5 +1,6 @@ --- title: Interlisp Basics for Common Lisp users +description: "Bring your Common Lisp habits to Medley. See where the two dialects diverge and get practical advice for translating code and avoiding common pitfalls." url: /software/using-medley/cl-using/ type: docs weight: 30 diff --git a/content/en/software/using-medley/il-using.md b/content/en/software/using-medley/il-using.md index cd1c2db4f..a566fafcf 100644 --- a/content/en/software/using-medley/il-using.md +++ b/content/en/software/using-medley/il-using.md @@ -1,5 +1,6 @@ --- title: Using Medley Interlisp Basics +description: "A hands-on introduction to working in Medley, from your first commands at the prompt to looking things up in the built-in reference and writing programs." weight: 20 type: docs --- diff --git a/content/en/software/using-medley/keystrokes.md b/content/en/software/using-medley/keystrokes.md index 21ea8c2f4..89a0a3c8b 100644 --- a/content/en/software/using-medley/keystrokes.md +++ b/content/en/software/using-medley/keystrokes.md @@ -1,5 +1,6 @@ --- title: Keystrokes, Mouse menus and Gestures +description: "Quick reference for the keyboard and mouse conventions Medley uses, so you can navigate and edit efficiently, with notes for macOS and other platforms." url: /software/using-medley/keystrokes/ weight: 40 type: docs diff --git a/content/en/software/using-medley/online-using.md b/content/en/software/using-medley/online-using.md index 4ab6a88ea..b21520b87 100644 --- a/content/en/software/using-medley/online-using.md +++ b/content/en/software/using-medley/online-using.md @@ -1,5 +1,6 @@ --- title: Using Medley Online +description: "Orientation to the browser-based Medley desktop: what you will see when it starts and the controls and conveniences available to people using Medley online." weight: 40 type: docs aliases: diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html index 874b053f9..092f8eeb6 100644 --- a/layouts/_partials/hooks/head-end.html +++ b/layouts/_partials/hooks/head-end.html @@ -34,5 +34,4 @@ self-guards so only the relevant schema is emitted per page. */ -}} {{ partial "schema-organization.html" . }} {{ partial "schema-article.html" . }} -{{ partial "schema-faq.html" . }} {{ partial "schema-breadcrumb.html" . }} diff --git a/layouts/_partials/page-description.html b/layouts/_partials/page-description.html index 0d5bfd2ce..a7a176c96 100644 --- a/layouts/_partials/page-description.html +++ b/layouts/_partials/page-description.html @@ -1,17 +1,23 @@ {{- /* Custom page description for meta tags. - For bibliography pages, include the title in the description. - Falls back to Docsy's default behavior for other pages. + Resolution order: + 1. Bibliography pages: " - <abstract>" (or "<title> by <authors>"). + 2. Any other page: front matter `description`, then the page `.Summary`, + then a generated description for taxonomy/term pages, then the site + default description. + The final string is normalized (plain text, single-spaced) and capped at + ~160 characters so it renders well in search results. */ -}} +{{- $desc := "" -}} {{- if eq .Type "bibliography" -}} - {{- /* Bibliography page: construct description with title */ -}} + {{- /* Bibliography page: construct description from title + abstract/authors */ -}} {{- $title := .Title | plainify | htmlUnescape | replaceRE "\\s+" " " | strings.TrimSpace -}} - {{- $desc := "" -}} + {{- $abstract := "" -}} {{- with .Params.abstract -}} - {{- $desc = . | plainify | htmlUnescape | replaceRE "\\s+" " " | strings.TrimSpace | truncate 200 -}} + {{- $abstract = . | plainify | htmlUnescape | replaceRE "\\s+" " " | strings.TrimSpace -}} {{- end -}} - {{- if $desc -}} - {{- printf "%s - %s" $title $desc -}} + {{- if $abstract -}} + {{- $desc = printf "%s - %s" $title $abstract -}} {{- else -}} {{- $authors := "" -}} {{- with .Params.authors -}} @@ -22,14 +28,38 @@ {{- end -}} {{- end -}} {{- if $authors -}} - {{- printf "%s by %s" $title $authors -}} + {{- $desc = printf "%s by %s" $title $authors -}} {{- else -}} - {{- $title -}} + {{- $desc = $title -}} {{- end -}} {{- end -}} {{- else -}} - {{- /* Default Docsy behavior for other pages */ -}} - {{- with or .Description .Summary site.Params.description | plainify | htmlUnescape -}} - {{- trim . "\n\r\t " -}} + {{- with .Description -}} + {{- $desc = . -}} + {{- else -}} + {{- with .Summary -}} + {{- $desc = . -}} + {{- else -}} + {{- /* Generated descriptions for auto-created taxonomy pages */ -}} + {{- if eq .Kind "term" -}} + {{- if eq .Data.Singular "author" -}} + {{- $desc = printf "Medley Interlisp bibliography entries authored by %s." .Title -}} + {{- else if eq .Data.Singular "item_type" -}} + {{- $desc = printf "Medley Interlisp bibliography entries of type \"%s\"." .Title -}} + {{- else -}} + {{- $desc = printf "Medley Interlisp bibliography entries for %s." .Title -}} + {{- end -}} + {{- else if eq .Kind "taxonomy" -}} + {{- $desc = printf "Browse an index of the %s taxonomy used to organize entries in the Medley Interlisp bibliography, with links to related publications, authors, and resources." (.Title | lower) -}} + {{- else -}} + {{- $desc = site.Params.description -}} + {{- end -}} + {{- end -}} {{- end -}} {{- end -}} +{{- /* Normalize and cap length for SERP display. */ -}} +{{- $desc = $desc | plainify | htmlUnescape | replaceRE "\\s+" " " | strings.TrimSpace -}} +{{- if gt (len $desc) 160 -}} + {{- $desc = $desc | truncate 157 -}} +{{- end -}} +{{- $desc -}} diff --git a/layouts/_partials/schema-faq.html b/layouts/_partials/schema-faq.html deleted file mode 100644 index 5ca27c0ef..000000000 --- a/layouts/_partials/schema-faq.html +++ /dev/null @@ -1,50 +0,0 @@ -{{- /* - schema-faq.html - Emits FAQPage JSON-LD derived from the page's H2 sections. - - Opt-in per page via front matter: - schema: faq # single value - schema: ["faq", ...] # list form also accepted - - Each top-level <h2> is treated as a Question and the HTML that follows it - (up to the next <h2>) as the accepted Answer. HTML is reduced to plain text. -*/ -}} -{{- $schema := .Params.schema | default "" -}} -{{- $isFaq := false -}} -{{- if reflect.IsSlice $schema -}} - {{- if in $schema "faq" -}}{{- $isFaq = true -}}{{- end -}} -{{- else if eq $schema "faq" -}} - {{- $isFaq = true -}} -{{- end -}} -{{- if $isFaq -}} -{{- $qa := slice -}} -{{- $parts := split .Content "<h2" -}} -{{- range $i, $p := $parts -}} - {{- if gt $i 0 -}} - {{- $sub := split $p "</h2>" -}} - {{- $head := index $sub 0 -}} - {{- $question := index (split $head ">") 1 | plainify | htmlUnescape | replaceRE `\s+` " " | strings.TrimSpace -}} - {{- $answer := "" -}} - {{- if gt (len $sub) 1 -}} - {{- $answer = index $sub 1 | plainify | htmlUnescape | replaceRE `\s+` " " | strings.TrimSpace -}} - {{- end -}} - {{- if and $question $answer -}} - {{- $qa = $qa | append (dict - "@type" "Question" - "name" $question - "acceptedAnswer" (dict "@type" "Answer" "text" $answer) - ) -}} - {{- end -}} - {{- end -}} -{{- end -}} -{{- if gt (len $qa) 0 -}} -{{- $ld := dict - "@context" "https://schema.org" - "@type" "FAQPage" - "url" .Permalink - "inLanguage" "en-US" - "mainEntity" $qa --}} -{{- printf "<script type=\"application/ld+json\">\n%s\n</script>" ($ld | jsonify (dict "indent" " ")) | safeHTML -}} -{{- end -}} -{{- end -}} diff --git a/tests/test_meta_descriptions.py b/tests/test_meta_descriptions.py new file mode 100644 index 000000000..2b241a111 --- /dev/null +++ b/tests/test_meta_descriptions.py @@ -0,0 +1,199 @@ +""" +tests/test_meta_descriptions.py +=============================== +Validates the site-wide ``<meta name="description">`` output added in Phase 1.3 +of ``docs/SEO_IMPROVEMENT_PLAN.md``. + +The description for every page is produced by +``layouts/_partials/page-description.html`` and injected by Docsy's ``head.html``. + +What is checked +--------------- +* Every rendered page (except alias/redirect pages) has a non-empty description. +* No description exceeds the hard cap of 200 characters. +* Content pages (the ones a person actually navigates to) are within the + 150-160 character sweet spot. +* Bibliography descriptions are capped at 160 characters. +* Content-page descriptions are unique. + +Redirect/alias pages (Hugo emits ``<meta http-equiv="refresh">`` for these) are +skipped: their canonical points at another page and they are ``noindex``. + +Usage +----- + pytest tests/test_meta_descriptions.py -v + PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_meta_descriptions.py -v +""" + +import html as html_mod +import re +from pathlib import Path + +import pytest + +from conftest import TEST_PUBLIC + +# `<meta name="description" content="...">`, tolerating either attribute order. +_META_DESC_PATTERNS = ( + re.compile(r'<meta\s+name=["\']description["\']\s+content=["\'](.*?)["\']', re.I | re.S), + re.compile(r'<meta\s+content=["\'](.*?)["\']\s+name=["\']description["\']', re.I | re.S), +) +_REFRESH_RE = re.compile(r'http-equiv=["\']refresh["\']', re.I) + +# A content page is one a person navigates to. Excluded: alias pages, the 404 +# page, auto-generated taxonomy pages, and bibliography pages. +_TAXONOMY_LISTS = {"authors", "categories", "tags", "item_type", "author"} +_EXCLUDED_PREFIXES = ("author/", "item_type/") + +# Bibliography pages are identified from the rendered HTML, not the URL: an +# entry page (``layouts/bibliography/single.html``) contains the marker below, +# and the section index is the listing page. Hand-written ``type: docs`` pages +# that merely live under the bibliography tree (``intake/``, +# ``zotero-resources/``) lack the marker and are ordinary content pages. +_BIBLIOGRAPHY_SECTION = "history/bibliography" +_BIBLIOGRAPHY_ENTRY_MARKER = 'class="bibliography-entry"' + + +def _built_pages() -> list[Path]: + if not TEST_PUBLIC.exists(): + pytest.skip( + f"Testing build not found at {TEST_PUBLIC}. Run " + "'hugo --environment testing --destination tests/public_test'." + ) + pages = sorted(TEST_PUBLIC.rglob("index.html")) + not_found = TEST_PUBLIC / "404.html" + if not_found.exists(): + pages.append(not_found) + return pages + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _is_redirect(html: str) -> bool: + return bool(_REFRESH_RE.search(html)) + + +def _description(html: str) -> str | None: + for pattern in _META_DESC_PATTERNS: + m = pattern.search(html) + if m: + # Descriptions are HTML-escaped inside the attribute (e.g. ' for + # an apostrophe). Measure the visible text, as a search engine would. + return html_mod.unescape(m.group(1)) + return None + + +def _slug(path: Path) -> str: + """URL-style slug of a built page, e.g. 'software/index.html' -> 'software'.""" + if path.name == "404.html": + return "404" + rel = path.parent.relative_to(TEST_PUBLIC) + return "" if str(rel) == "." else str(rel).replace("\\", "/") + + +def _is_bibliography_entry(html: str) -> bool: + return _BIBLIOGRAPHY_ENTRY_MARKER in html + + +def _is_bibliography(slug: str, html: str) -> bool: + """Auto-generated bibliography entries, plus the section index page.""" + return _is_bibliography_entry(html) or slug == _BIBLIOGRAPHY_SECTION + + +def _is_content_page(slug: str, html: str) -> bool: + if slug == "404" or slug in _TAXONOMY_LISTS: + return False + if any(slug == p.rstrip("/") or slug.startswith(p) for p in _EXCLUDED_PREFIXES): + return False + if _is_bibliography(slug, html): + return False + return True + + +@pytest.fixture(scope="module") +def pages() -> dict[str, str]: + """Map slug -> rendered HTML for every non-redirect built page.""" + out: dict[str, str] = {} + for path in _built_pages(): + html = _read(path) + if _is_redirect(html): + continue + out[_slug(path)] = html + assert out, "No non-redirect pages were found in the testing build" + return out + + +# =========================================================================== +# Presence +# =========================================================================== + + +def test_every_page_has_a_description(pages: dict[str, str]) -> None: + missing = [ + slug for slug, html in pages.items() if not (_description(html) or "").strip() + ] + assert not missing, "Pages missing a meta description:\n" + "\n".join(sorted(missing)) + + +# =========================================================================== +# Length +# =========================================================================== + + +def test_no_description_exceeds_hard_cap(pages: dict[str, str]) -> None: + over = [ + (slug, len(desc)) + for slug, html in pages.items() + if (desc := _description(html)) is not None and len(desc) > 200 + ] + assert not over, "Descriptions over 200 characters:\n" + "\n".join( + f"{slug}: {n}" for slug, n in sorted(over) + ) + + +def test_content_pages_in_target_range(pages: dict[str, str]) -> None: + bad = [] + for slug, html in pages.items(): + if not _is_content_page(slug, html): + continue + desc = _description(html) + assert desc is not None + if not 150 <= len(desc) <= 160: + bad.append((slug, len(desc))) + assert not bad, "Content pages outside 150-160 characters:\n" + "\n".join( + f"{slug}: {n}" for slug, n in sorted(bad) + ) + + +def test_bibliography_descriptions_capped(pages: dict[str, str]) -> None: + bad = [] + for slug, html in pages.items(): + if not _is_bibliography(slug, html): + continue + desc = _description(html) or "" + if len(desc) > 160: + bad.append((slug, len(desc))) + assert not bad, "Bibliography descriptions over 160 characters:\n" + "\n".join( + f"{slug}: {n}" for slug, n in sorted(bad) + ) + + +# =========================================================================== +# Uniqueness +# =========================================================================== + + +def test_content_descriptions_are_unique(pages: dict[str, str]) -> None: + seen: dict[str, list[str]] = {} + for slug, html in pages.items(): + if not _is_content_page(slug, html): + continue + desc = _description(html) + if desc: + seen.setdefault(desc, []).append(slug) + dupes = {d: slugs for d, slugs in seen.items() if len(slugs) > 1} + assert not dupes, "Duplicate content-page descriptions:\n" + "\n".join( + f"{d!r} -> {sorted(slugs)}" for d, slugs in sorted(dupes.items()) + ) diff --git a/tests/test_site_jsonld.py b/tests/test_site_jsonld.py index a30dec867..0dff2837e 100644 --- a/tests/test_site_jsonld.py +++ b/tests/test_site_jsonld.py @@ -6,8 +6,6 @@ Organization → home page (layouts/_partials/schema-organization.html) Article → regular content pages (layouts/_partials/schema-article.html) - FAQPage → pages with `schema: faq` front matter - (layouts/_partials/schema-faq.html) BreadcrumbList→ every non-home page (layouts/_partials/schema-breadcrumb.html) These complement ``test_bibliography_jsonld.py``, which covers the @@ -31,7 +29,6 @@ PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_site_jsonld.py -v """ -import html as html_mod import json import re from html.parser import HTMLParser @@ -124,7 +121,6 @@ def _base_url() -> str: HOME = TEST_PUBLIC / "index.html" -FAQS = TEST_PUBLIC / "project" / "faqs" / "index.html" PRIVACY = TEST_PUBLIC / "privacy" / "index.html" NESTED = ( TEST_PUBLIC / "software" / "install-and-run" / "linux" @@ -233,51 +229,6 @@ def test_section_pages_have_no_article(self): ), "Section page must not emit an Article block" -# =========================================================================== -# FAQPage -# =========================================================================== - - -class TestFaqPage: - """Pages opted in with `schema: faq` emit a FAQPage block.""" - - @pytest.fixture(autouse=True) - def ld(self): - self.ld = _find(FAQS, "FAQPage") - - def test_context_and_type(self): - assert self.ld["@context"] == "https://schema.org" - assert self.ld["@type"] == "FAQPage" - - def test_has_questions(self): - assert len(self.ld["mainEntity"]) >= 1 - - def test_each_entry_is_question_with_answer(self): - for q in self.ld["mainEntity"]: - assert q["@type"] == "Question" - assert q["name"].strip() - ans = q["acceptedAnswer"] - assert ans["@type"] == "Answer" - assert ans["text"].strip() - - def test_questions_match_h2_headings(self): - """Each H2 on the page becomes one question, in document order.""" - html = FAQS.read_text(encoding="utf-8") - headings = [ - re.sub(r"\s+", " ", html_mod.unescape(re.sub(r"<[^>]+>", "", h))).strip() - for h in re.findall(r"<h2[^>]*>(.*?)</h2>", html, re.S) - ] - names = [q["name"] for q in self.ld["mainEntity"]] - assert names == headings - - def test_no_html_entities_in_text(self): - """plainify + htmlUnescape must leave no raw entities behind.""" - blob = json.dumps(self.ld) - assert not re.search(r"&[a-zA-Z]+;", blob), ( - "HTML entity leaked into FAQPage text" - ) - - # =========================================================================== # BreadcrumbList # =========================================================================== @@ -328,31 +279,9 @@ def test_organization_only_on_home() -> None: assert not has_org, f"Organization block unexpectedly on {page}" -def test_faqpage_only_where_opted_in() -> None: - """FAQPage appears only on pages carrying `schema: faq` front matter.""" - content_root = REPO_ROOT / "content" / "en" - opted_in: set[str] = set() - for md in content_root.rglob("*.md"): - text = md.read_text(encoding="utf-8") - front = text.split("---", 2) - if len(front) >= 3 and re.search(r"^schema:\s*(faq|\['?faq)", front[1], re.M): - rel = md.relative_to(content_root) - slug = rel.parent / rel.stem if rel.name == "_index.md" else rel.with_suffix("") - opted_in.add(str(slug).replace("\\", "/")) - - for page in _all_pages(): - has_faq = any(d.get("@type") == "FAQPage" for d in _jsonld_blocks(page)) - if has_faq: - rel = str(page.parent.relative_to(TEST_PUBLIC)).replace("\\", "/") - rel = "" if rel == "." else rel - assert rel in opted_in, ( - f"FAQPage emitted on {page} but its source has no `schema: faq`" - ) - - def test_no_duplicate_structured_data_blocks() -> None: """No page emits more than one of the singleton schema types.""" - singletons = ("Organization", "FAQPage", "BreadcrumbList") + singletons = ("Organization", "BreadcrumbList") problems: list[str] = [] for page in _all_pages(): blocks = _jsonld_blocks(page) From c687993a91ec04aee639f91b6838acedce7cca24 Mon Sep 17 00:00:00 2001 From: Bill Stumbo <wstumbo@charter.net> Date: Thu, 12 Nov 2026 14:45:08 -0500 Subject: [PATCH 4/4] Update robots.txt to not index the search page. --- README.md | 5 +++++ content/en/search.md | 1 + layouts/robots.txt | 8 ++++++++ 3 files changed, 14 insertions(+) diff --git a/README.md b/README.md index c93f36c0d..7e719c93b 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,11 @@ gcs_engine_id: 33ef4cbe0703b4f3a Search results are rendered using the `search.html` layout template. +The search page is intentionally kept out of search-engine indexes: `robots.txt` disallows +`/search/`, and `content/en/search.md` sets `robots = "noindex"` (which also excludes it from the +sitemap). The page is a thin wrapper around the Google Custom Search widget with no content of its +own. + **Updating Search:** Modifying search scope requires updating the Google Custom Search engine settings via the [Programmable Search Engine Dashboard](https://programmablesearchengine.google.com). Access is restricted. To suggest changes, open an issue: [Search Engine Issue](https://github.com/interlisp/medley/issues/new?template=bug_report.md&title=Search_Engine_Issue) diff --git a/content/en/search.md b/content/en/search.md index df6698621..848e6bfd7 100644 --- a/content/en/search.md +++ b/content/en/search.md @@ -2,4 +2,5 @@ title = "Search Results" description = "Search Interlisp.org to find documentation, history, software guides, and bibliography entries about Medley Interlisp and the historic Interlisp-D system." layout = "search" +robots = "noindex" +++ \ No newline at end of file diff --git a/layouts/robots.txt b/layouts/robots.txt index f59e94455..043cf4481 100644 --- a/layouts/robots.txt +++ b/layouts/robots.txt @@ -2,3 +2,11 @@ # User-agent: * Allow: / + +# The internal Google Custom Search results page is a thin, content-free +# utility page. Keep it out of the index: block the crawl here and (belt and +# suspenders) mark it noindex via the `robots` front matter in +# content/en/search.md, which also drops it from the sitemap. +Disallow: /search/ + +Sitemap: {{ "sitemap.xml" | absURL }}