Skip to content

Commit 91858f0

Browse files
committed
feat(website): describe the product on /download, not just on the landing page
/download carried only the site-wide Organization and WebSite pair, so the one URL we want ranking for "openscreen download" said nothing about the software it serves. It now emits a @graph: a WebPage node that is part of the site-wide WebSite and whose about/mainEntity is the product, plus the SoftwareApplication itself. Referencing #software without defining it would have left a dangling pointer with nothing for a crawler to attach to this URL, so the entity is emitted here too — not duplication, since the shared @id makes both copies one entity rather than two competing ones. Docs pages still get neither. The node moves to src/lib/structured-data.ts so the two pages cannot drift, and the download page's copy carries softwareVersion and datePublished off the build-time release lookup it already reads. That needs the release date in schema.org's Date form, hence publishedIso alongside the display string. Verified against the production build: tsc and biome clean, all four nodes present in the served DOM with no hydration errors, landing page output unchanged.
1 parent 3f0ec8a commit 91858f0

5 files changed

Lines changed: 135 additions & 45 deletions

File tree

website/docusaurus.config.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ const SITE_DESCRIPTION =
1616

1717
// Site-wide structured data. Organization + WebSite are true of every page, so
1818
// they belong here; the SoftwareApplication entity describes the product rather
19-
// than the site and is emitted on the landing page only (src/pages/index.tsx),
20-
// because duplicating it under every docs URL is what earns a manual action.
19+
// than the site and lives in src/lib/structured-data.ts, emitted only by the two
20+
// pages that are about the product (the landing page and /download), because
21+
// repeating it under every docs URL is what earns a manual action.
2122
const ORGANIZATION_LD = {
2223
"@context": "https://schema.org",
2324
"@type": "Organization",
@@ -121,12 +122,18 @@ async function fetchLatestRelease(): Promise<LatestRelease> {
121122
// resolve against the visitor's locale and time zone on hydration and
122123
// mismatch the server-rendered string.
123124
let published = "";
125+
let publishedIso = "";
124126
if (typeof data.published_at === "string") {
125127
const [y, m, d] = data.published_at.slice(0, 10).split("-");
126-
if (y && m && d) published = `${Number(d)} ${MONTHS[Number(m) - 1]} ${y}`;
128+
if (y && m && d) {
129+
published = `${Number(d)} ${MONTHS[Number(m) - 1]} ${y}`;
130+
// Kept alongside the display string for /download's structured
131+
// data, which needs schema.org's Date form rather than prose.
132+
publishedIso = `${y}-${m}-${d}`;
133+
}
127134
}
128135

129-
return { tag: data.tag_name, published, assets };
136+
return { tag: data.tag_name, published, publishedIso, assets };
130137
} catch {
131138
return null;
132139
}

website/src/lib/release.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type LatestRelease = {
1515
tag: string;
1616
/** Pre-formatted at build time, e.g. "19 July 2026". Empty if unknown. */
1717
published: string;
18+
/** The same date as YYYY-MM-DD, for structured data. Empty if unknown. */
19+
publishedIso: string;
1820
assets: ReleaseAsset[];
1921
} | null;
2022

website/src/lib/structured-data.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Schema.org nodes for the product, shared by the two pages that are genuinely
3+
* about it: the landing page and /download.
4+
*
5+
* The Organization and WebSite pair lives in docusaurus.config.ts instead,
6+
* because it is true of every URL and is emitted from headTags. The product
7+
* entity deliberately is not: a SoftwareApplication repeated under every docs
8+
* page is what earns a manual action. Emitting it on the two pages that
9+
* describe the product costs nothing, because both use the same @id — search
10+
* engines reconcile them into one entity rather than two competing copies.
11+
*/
12+
13+
import type { LatestRelease } from "./release";
14+
15+
const SITE_URL = "https://getopenscreen.com";
16+
17+
/** Minted to match the @ids in docusaurus.config.ts; keep the two in step. */
18+
export const ORGANIZATION_ID = `${SITE_URL}/#organization`;
19+
export const WEBSITE_ID = `${SITE_URL}/#website`;
20+
export const SOFTWARE_ID = `${SITE_URL}/#software`;
21+
22+
const SOFTWARE_APPLICATION_LD = {
23+
"@type": "SoftwareApplication",
24+
"@id": SOFTWARE_ID,
25+
name: "OpenScreen",
26+
applicationCategory: "MultimediaApplication",
27+
applicationSubCategory: "Screen Recorder",
28+
operatingSystem: "Windows, macOS, Linux",
29+
description:
30+
"Free, open-source screen recorder and video editor. Native capture on macOS and Windows, multi-track timeline editing, on-device Whisper captions, and MP4/GIF export — no watermarks, no subscription, no account.",
31+
url: SITE_URL,
32+
// Our own page rather than the Releases list: it is the URL we want ranking
33+
// for "openscreen download", and it routes to GitHub from there anyway.
34+
downloadUrl: `${SITE_URL}/download/`,
35+
installUrl: "https://github.com/getopenscreen/openscreen/releases",
36+
softwareHelp: `${SITE_URL}/docs/intro/`,
37+
license: "https://github.com/getopenscreen/openscreen/blob/main/LICENSE",
38+
isAccessibleForFree: true,
39+
// `offers` at price 0 is what lets a result carry a "Free" annotation;
40+
// omitting it on a free app just forfeits the label.
41+
offers: {
42+
"@type": "Offer",
43+
price: "0",
44+
priceCurrency: "USD",
45+
},
46+
featureList: [
47+
"Native screen capture (ScreenCaptureKit, Windows Graphics Capture)",
48+
"Multi-track timeline editing with zoom, trim, and speed regions",
49+
"On-device Whisper transcription and burned-in captions",
50+
"Webcam picture-in-picture and cursor smoothing",
51+
"MP4 (H.264/H.265) and animated GIF export",
52+
],
53+
publisher: { "@id": ORGANIZATION_ID },
54+
};
55+
56+
/**
57+
* The product entity, carrying the version and release date wherever the caller
58+
* has the build-time release lookup to hand. Those two properties belong to the
59+
* same @id as the bare node, so a page that knows the current version and one
60+
* that doesn't describe one entity, not a contradiction.
61+
*/
62+
export function softwareApplicationLd(release?: LatestRelease) {
63+
if (!release) return SOFTWARE_APPLICATION_LD;
64+
return {
65+
...SOFTWARE_APPLICATION_LD,
66+
// Tags are minted as v1.8.0; schema.org wants the version alone.
67+
softwareVersion: release.tag.replace(/^v/, ""),
68+
...(release.publishedIso ? { datePublished: release.publishedIso } : {}),
69+
};
70+
}
71+
72+
/**
73+
* Serializes nodes under the document-level @context. Several nodes become an
74+
* @graph rather than one <script> apiece, so cross-references between them
75+
* resolve within a single document.
76+
*/
77+
export function jsonLd(...nodes: object[]): string {
78+
const body = nodes.length === 1 ? nodes[0] : { "@graph": nodes };
79+
return JSON.stringify({ "@context": "https://schema.org", ...body });
80+
}

website/src/pages/download.tsx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Head from "@docusaurus/Head";
12
import Link from "@docusaurus/Link";
23
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
34
import Heading from "@theme/Heading";
@@ -12,11 +13,19 @@ import {
1213
} from "lucide-react";
1314

1415
import { type AssetKind, findAsset, formatSize, type LatestRelease } from "../lib/release";
16+
import { jsonLd, SOFTWARE_ID, softwareApplicationLd, WEBSITE_ID } from "../lib/structured-data";
1517
import styles from "./download.module.css";
1618

1719
const REPO_URL = "https://github.com/getopenscreen/openscreen";
1820
const RELEASES_URL = `${REPO_URL}/releases`;
1921
const LATEST_URL = `${RELEASES_URL}/latest`;
22+
const PAGE_URL = "https://getopenscreen.com/download/";
23+
24+
// Shared by <Layout> and the WebPage node below so the two cannot drift: a
25+
// structured-data description that contradicts the meta one is worse than none.
26+
const PAGE_TITLE = "Download for Windows, macOS & Linux";
27+
const PAGE_DESCRIPTION =
28+
"Download OpenScreen free for Windows, macOS, and Linux — .dmg, .exe, .deb, .pacman, AppImage, and a Nix flake. Open source, no account, no watermark.";
2029

2130
type PlatformSpec = {
2231
id: string;
@@ -58,15 +67,40 @@ const PLATFORMS: PlatformSpec[] = [
5867
},
5968
];
6069

70+
/**
71+
* Hooks this URL onto the site's entity graph: a WebPage node that is part of
72+
* the site-wide WebSite and whose subject is the product entity, plus that
73+
* entity itself under its canonical @id. Emitting the SoftwareApplication here
74+
* as well as on the landing page is not duplication — the shared @id makes both
75+
* copies one entity — and it is what lets this page, the one we want ranking for
76+
* "openscreen download", carry the app's category, platforms, price, and version.
77+
*/
78+
function downloadPageLd(release: LatestRelease): string {
79+
return jsonLd(
80+
{
81+
"@type": "WebPage",
82+
"@id": `${PAGE_URL}#webpage`,
83+
url: PAGE_URL,
84+
name: PAGE_TITLE,
85+
description: PAGE_DESCRIPTION,
86+
inLanguage: "en",
87+
isPartOf: { "@id": WEBSITE_ID },
88+
about: { "@id": SOFTWARE_ID },
89+
mainEntity: { "@id": SOFTWARE_ID },
90+
},
91+
softwareApplicationLd(release),
92+
);
93+
}
94+
6195
export default function DownloadPage() {
6296
const { siteConfig } = useDocusaurusContext();
6397
const release = (siteConfig.customFields?.latestRelease ?? null) as LatestRelease;
6498

6599
return (
66-
<Layout
67-
title="Download for Windows, macOS & Linux"
68-
description="Download OpenScreen free for Windows, macOS, and Linux — .dmg, .exe, .deb, .pacman, AppImage, and a Nix flake. Open source, no account, no watermark."
69-
>
100+
<Layout title={PAGE_TITLE} description={PAGE_DESCRIPTION}>
101+
<Head>
102+
<script type="application/ld+json">{downloadPageLd(release)}</script>
103+
</Head>
70104
<header className={styles.hero}>
71105
<div className={styles.heroInner}>
72106
<span className={styles.badge}>

website/src/pages/index.tsx

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
TerminalSquare,
1818
} from "lucide-react";
1919

20+
import { jsonLd, softwareApplicationLd } from "../lib/structured-data";
2021
import styles from "./index.module.css";
2122

2223
// Static decorative waveform bars for the "multi-track editor" bento card —
@@ -25,50 +26,16 @@ import styles from "./index.module.css";
2526
const MINI_WAVEFORM_A = [30, 55, 80, 45, 65, 90, 50, 35, 70, 60, 40, 75, 55, 30];
2627
const MINI_WAVEFORM_B = [45, 65, 35, 85, 55, 40, 70, 90, 50, 30, 60, 75, 45, 65, 35, 55];
2728

28-
// The product entity, distinct from the Organization/WebSite pair in
29-
// docusaurus.config.ts. `offers` at price 0 is what lets a result carry a
30-
// "Free" annotation; omitting it on a free app just forfeits the label.
31-
const SOFTWARE_APPLICATION_LD = {
32-
"@context": "https://schema.org",
33-
"@type": "SoftwareApplication",
34-
"@id": "https://getopenscreen.com/#software",
35-
name: "OpenScreen",
36-
applicationCategory: "MultimediaApplication",
37-
applicationSubCategory: "Screen Recorder",
38-
operatingSystem: "Windows, macOS, Linux",
39-
description:
40-
"Free, open-source screen recorder and video editor. Native capture on macOS and Windows, multi-track timeline editing, on-device Whisper captions, and MP4/GIF export — no watermarks, no subscription, no account.",
41-
url: "https://getopenscreen.com",
42-
// Our own page rather than the Releases list: it is the URL we want ranking
43-
// for "openscreen download", and it routes to GitHub from there anyway.
44-
downloadUrl: "https://getopenscreen.com/download/",
45-
installUrl: "https://github.com/getopenscreen/openscreen/releases",
46-
softwareHelp: "https://getopenscreen.com/docs/intro/",
47-
license: "https://github.com/getopenscreen/openscreen/blob/main/LICENSE",
48-
isAccessibleForFree: true,
49-
offers: {
50-
"@type": "Offer",
51-
price: "0",
52-
priceCurrency: "USD",
53-
},
54-
featureList: [
55-
"Native screen capture (ScreenCaptureKit, Windows Graphics Capture)",
56-
"Multi-track timeline editing with zoom, trim, and speed regions",
57-
"On-device Whisper transcription and burned-in captions",
58-
"Webcam picture-in-picture and cursor smoothing",
59-
"MP4 (H.264/H.265) and animated GIF export",
60-
],
61-
publisher: { "@id": "https://getopenscreen.com/#organization" },
62-
};
63-
6429
export default function Home() {
6530
return (
6631
<Layout
6732
title="Free open-source screen recorder & video editor"
6833
description="OpenScreen is a free, open-source screen recorder and video editor for Windows, macOS, and Linux — native capture, on-device captions, no watermarks."
6934
>
7035
<Head>
71-
<script type="application/ld+json">{JSON.stringify(SOFTWARE_APPLICATION_LD)}</script>
36+
{/* The product entity, distinct from the Organization/WebSite pair
37+
emitted site-wide from docusaurus.config.ts. */}
38+
<script type="application/ld+json">{jsonLd(softwareApplicationLd())}</script>
7239
</Head>
7340
<header className={styles.hero}>
7441
<div className={styles.heroInner}>

0 commit comments

Comments
 (0)