diff --git a/README.md b/README.md index 08d1b9a..7a9ff43 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ authoritative record. - **The gaps stay visible.** Missing dates, unmatched publication types and populated sections omitted from a page appear in generated provenance instead of being silently invented. - **It is a real site, not a CV pasted into HTML.** Blog posts, RSS, sitemap, search, dark mode, - responsive layouts, accessible navigation and GitHub Pages deployment are included. + responsive layouts, accessible navigation, social link previews and GitHub Pages deployment are + included. The name is literal: a **ledger** is the record kept once and required to balance; the **press** publishes it. The product is **ledgerpress**. **Ledger** is the design system inside it—the @@ -96,7 +97,7 @@ high-contrast editorial interface in `web/`, including the Ledger Serif type fam - **[Bundled example](https://eduardstan.github.io/ledgerpress/)**: The template's own fictional palaeoclimatologist, Dr. Sahana Aster Kōwhai. Her record exercises cross-appointments, non-ASCII names, talks, teaching tables, projects, service, fieldwork kept off the printed CV via - `printed: false`, a post and an SVG portrait. + `printed: false`, a post and a PNG portrait. - **[eduardstan.github.io](https://eduardstan.github.io/)**: The real personal site and CV from which ledgerpress was extracted. @@ -291,6 +292,7 @@ something else. - Add migration redirects in `web/src/lib/legacy-urls.ts`. - Change or add website routes in `web/src/pages/`. - Change announcement wording in the `TEMPLATES` table of `web/src/lib/announcements.ts`. +- Edit or delete the "Built with ledgerpress" credit line in `web/src/components/Footer.astro`. None of them touches a fact about you, and taking a later ledgerpress update after one of them is a merge you resolve rather than a conflict with your record. diff --git a/content/README.md b/content/README.md index 843e4b3..94864fa 100644 --- a/content/README.md +++ b/content/README.md @@ -152,7 +152,8 @@ profile: bluesky: # every other service takes a label and URL; label: Bluesky # the site and PDF both render it without a url: https://bsky.app/profile/ada.example # machinery edit. - portrait: portrait.jpg # a file in content/media/ + portrait: portrait.jpg # a file in content/media/. A raster one also + # becomes the link-preview image; see `media/`. favicon: favicon.svg # another file in content/media/. Optional: if it # is omitted or missing, no icon link is emitted. bio: @@ -502,8 +503,10 @@ draft: false Images. `profile.portrait` and the optional `profile.favicon` name files here by bare filename; **inside a post or an item, refer to one as `/media/`** — the directory is published at -that address. The build prefixes the project path from `profile.site` when the site is deployed -below an origin root. +that address. The portrait also supplies the Open Graph and Twitter card image when it is a +`.png`, `.jpg`, `.jpeg` or `.webp` file. Other formats, including SVG, still display on the site +but emit no social image tag. The build prefixes the project path from `profile.site` when the +site is deployed below an origin root. ## Announcements @@ -544,7 +547,7 @@ Most fields reach both the website and the PDF. These land somewhere their names | Field | Website | Printed CV | | --- | --- | --- | -| `profile.site` | the origin and base path of every canonical, feed and sitemap URL | never read | +| `profile.site` | the origin and base path of every canonical, feed, sitemap and social-preview URL | never read | | `profile.website` | not rendered | printed in the header contact line | | `profile.bio.short` | shown on `/cv/` as "Short bio" | printed as "Short Bio" | | `profile.bio.long` | the home page, in full, first sentence quoted above the fold | never printed | diff --git a/content/cv.yaml b/content/cv.yaml index ca2a1ed..9bea5f5 100644 --- a/content/cv.yaml +++ b/content/cv.yaml @@ -28,7 +28,7 @@ profile: bluesky: label: Bluesky url: https://bsky.app/profile/sahana-kowhai.example - portrait: portrait.svg + portrait: portrait.png favicon: favicon.svg bio: short: >- diff --git a/content/media/LICENSES.md b/content/media/LICENSES.md index edd0027..7caa8e8 100644 --- a/content/media/LICENSES.md +++ b/content/media/LICENSES.md @@ -1,8 +1,8 @@ # Example media -`portrait.svg`, `favicon.svg`, and `core-layers.svg` are original geometric illustrations created -for this ledgerpress example. They do not depict a real person and are licensed under the -repository's MIT licence. +`portrait.svg`, its raster `portrait.png` version, `favicon.svg`, and `core-layers.svg` are +original geometric illustrations created for this ledgerpress example. They do not depict a real +person and are licensed under the repository's MIT licence. Replace this file with the provenance and licence terms for your own media when you adopt the template. diff --git a/content/media/portrait.png b/content/media/portrait.png new file mode 100644 index 0000000..91779ba Binary files /dev/null and b/content/media/portrait.png differ diff --git a/scripts/check-deployment-base.mjs b/scripts/check-deployment-base.mjs index a157dab..4a60200 100644 --- a/scripts/check-deployment-base.mjs +++ b/scripts/check-deployment-base.mjs @@ -70,9 +70,23 @@ try { const text = (path) => readFileSync(join(proofDist, path), "utf8"); const home = text("index.html"); assert.match(home, /href="\/ledgerpress-proof\/publications\/"/); + assert.ok(home.includes(`property="og:url" content="${site}"`), "og:url lost deployment site base"); + assert.ok(home.includes('property="og:type" content="website"')); + assert.ok(home.includes('name="twitter:card" content="summary"')); if (portrait) { assert.ok(home.includes(`src="${htmlAttribute(`${base}media/${portrait}`)}"`), `${portrait} lost the deployment base`); + if (/\.(?:png|jpe?g|webp)$/i.test(portrait)) { + assert.ok(home.includes(`property="og:image" content="${site}media/${portrait}"`), `og:image lost deployment site base`); + assert.ok(home.includes(`name="twitter:image" content="${site}media/${portrait}"`), `twitter:image lost deployment site base`); + } else { + assert.doesNotMatch(home, /property="og:image"/); + assert.doesNotMatch(home, /name="twitter:image"/); + } } + const alternatePublications = text("publications/year-asc/index.html"); + assert.ok(alternatePublications.includes(`rel="canonical" href="${site}publications/"`)); + assert.doesNotMatch(alternatePublications, /property="og:/); + assert.doesNotMatch(alternatePublications, /name="twitter:/); assert.match(home, /href="\/ledgerpress-proof\/fonts\/Archivo-Black\.woff2"/); assert.ok(home.includes(`>${homeCounts}<`), `home count is not "${homeCounts}"`); @@ -129,7 +143,7 @@ try { "cv", "404", ]; - const unbased = new RegExp(`(?:src|href)="/(?!${base.slice(1)})(?:${prefixes.join("|")})(?:/|")`); + const unbased = new RegExp(`(?:src|href|content)="/(?!${base.slice(1)})(?:${prefixes.join("|")})(?:/|")`); for (const page of pages) { assert.doesNotMatch(text(page), unbased, `${page}: an internal URL lost the deployment base`); } diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro index 0967044..ca499f2 100644 --- a/web/src/components/Footer.astro +++ b/web/src/components/Footer.astro @@ -65,5 +65,10 @@ const url = (path: string) => internalUrl(path, import.meta.env.BASE_URL); -

© {year} {name}

+ {/* Adopters may delete or edit the template credit line below. */} +

+ © {year}{' '}{name} · Built with ledgerpress +

diff --git a/web/src/layouts/BaseLayout.astro b/web/src/layouts/BaseLayout.astro index e83b0e0..f0defea 100644 --- a/web/src/layouts/BaseLayout.astro +++ b/web/src/layouts/BaseLayout.astro @@ -12,7 +12,8 @@ interface Props { /** * Where this page's content really lives, when it is an alternate view of * another page. One `` is emitted either way: a second, - * conflicting one would make search engines ignore both. + * conflicting one would make search engines ignore both. Alternate views + * leave Open Graph and Twitter metadata to the canonical page. */ canonical?: string; /** Keeps the page out of the Pagefind index — `
` is the indexed body. */ @@ -21,7 +22,7 @@ interface Props { const { name } = profile(); const base = import.meta.env.BASE_URL; -const { favicon } = identity(base); +const { favicon, portrait } = identity(base); const { title, description = `Personal site of ${name}.`, @@ -30,6 +31,13 @@ const { } = Astro.props; const canonical = canonicalPath ? absoluteInternalUrl(canonicalPath, Astro.site!, base) : Astro.url; const url = (path: string) => internalUrl(path, base); +/** Anything below the blog index is one post; the index itself is not. */ +const blogIndex = url('/blog/'); +const isPost = canonical.pathname.startsWith(blogIndex) && canonical.pathname !== blogIndex; +const socialImageUrl = + portrait && /\.(?:png|jpe?g|webp)$/i.test(portrait) + ? new URL(portrait, Astro.site!).href + : undefined; --- @@ -39,6 +47,20 @@ const url = (path: string) => internalUrl(path, base); {title} + { + canonicalPath === undefined && ( + <> + + + + + + + {socialImageUrl && } + {socialImageUrl && } + + ) + } {favicon && } {