Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions community/2026-05-27-community-meeting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ image: https://i.ytimg.com/vi/QyVyD37cvrw/maxresdefault.jpg
duration: 3252
showTitle: true
slug: 2026-05-27-community-meeting
about: wasmCloud
mentions:
- ComponentModel
- WASIPreview3
- WASI
- JCO
- ComponentizeJS
- StarlingMonkey
- Wasmtime
- BytecodeAlliance
- Java
- JavaScript
speakers:
- bailey-hayes
- liam-randall
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
"license": "Apache-2.0",
"scripts": {
"docusaurus": "docusaurus",
"prestart": "node scripts/generate-transcript-inheritance.mjs",
"start": "docusaurus start --config docusaurus.config.ts --no-open",
"prebuild": "node scripts/generate-transcript-inheritance.mjs",
"build": "docusaurus build",
"generate:transcript-inheritance": "node scripts/generate-transcript-inheritance.mjs",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
Expand Down
106 changes: 106 additions & 0 deletions scripts/generate-transcript-inheritance.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Build-time generator for `src/data/transcript-inheritance.json`.
*
* Community meetings have TWO pages each: a landing/watch page
* (`<date>-community-meeting.mdx`) and a transcript page
* (`<date>-community-meeting-transcript.mdx`). The landing page's
* frontmatter carries `about:` + `mentions:` entity references from the
* M12 dictionary; the transcript page covers the same content but its
* frontmatter typically does not repeat those refs. Without inheritance,
* the transcript's Article JSON-LD lacks the entity graph and trips the
* M10 validator's `no about or mentions` warning.
*
* This script reads every landing page's frontmatter and emits a single
* JSON map keyed by the transcript permalink (derived deterministically
* from the landing slug). `video-seo.tsx` imports the map at module
* load and applies the inherited refs when rendering a transcript page
* whose own frontmatter doesn't carry them.
*
* Idempotent. Safe to run before every build (it's wired into
* package.json's `prebuild` script).
*/
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const COMMUNITY_DIR = join(REPO_ROOT, 'community');
const OUT_PATH = join(REPO_ROOT, 'src', 'data', 'transcript-inheritance.json');

const LANDING_MDX_RE = /^(\d{4}-\d{2}-\d{2})-community-meeting\.mdx$/;
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;

/**
* Extract about/mentions from a landing page's frontmatter. Returns an
* empty result when neither field is present so the caller can decide
* whether to emit anything.
*/
async function readLandingRefs(path) {
const raw = await readFile(path, 'utf8');
const m = FRONTMATTER_RE.exec(raw);
if (!m) return null;
let fm;
try {
fm = yaml.load(m[1]);
} catch (e) {
console.warn(`[transcript-inheritance] yaml parse failed for ${path}: ${e.message}`);
return null;
}
if (!fm || typeof fm !== 'object') return null;
const out = {};
if (typeof fm.about === 'string' && fm.about) out.about = fm.about;
if (Array.isArray(fm.mentions)) {
const cleaned = fm.mentions.filter((m) => typeof m === 'string' && m.length > 0);
if (cleaned.length > 0) out.mentions = cleaned;
}
return Object.keys(out).length > 0 ? out : null;
}

async function main() {
const entries = await readdir(COMMUNITY_DIR);
const map = {};
let scanned = 0;
let withRefs = 0;

for (const entry of entries.sort()) {
const match = LANDING_MDX_RE.exec(entry);
if (!match) continue;
scanned += 1;
const date = match[1];
const refs = await readLandingRefs(join(COMMUNITY_DIR, entry));
if (!refs) continue;
withRefs += 1;
// Key by the transcript page's canonical permalink. The transcript
// slug is the landing slug with `-transcript` appended (set by
// `slug:` in the transcript MDX frontmatter, mirrored by the
// `transcriptUrlForMeeting` helper in video-seo.tsx).
const transcriptPermalink = `/community/${date}-community-meeting-transcript/`;
map[transcriptPermalink] = refs;
}

await mkdir(dirname(OUT_PATH), { recursive: true });
await writeFile(
OUT_PATH,
JSON.stringify(
{
$comment:
'Auto-generated by scripts/generate-transcript-inheritance.mjs (prebuild step). Edits will be overwritten. Maps transcript permalink → { about, mentions } from the parent landing page\'s frontmatter so video-seo.tsx can inherit entity refs.',
generated_at: new Date().toISOString(),
entries: map,
},
null,
2,
) + '\n',
);

console.log(
`[transcript-inheritance] scanned ${scanned} landing pages, wrote ${withRefs} entries → ${OUT_PATH.replace(REPO_ROOT + '/', '')}`,
);
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
13 changes: 12 additions & 1 deletion scripts/validate-structured-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,19 @@ function validatePayload(payload, ctx) {
return { errors, warnings };
}

// Pages that opt out of search indexing with `<meta name="robots" content="noindex...">`
// are intentionally not eligible for rich results. Soft warnings about authoring
// gaps (e.g. missing M12 about/mentions) provide zero SEO value on these pages,
// so we suppress them. Hard schema-validity errors still fire — invalid JSON-LD
// is invalid regardless of indexability.
// Tolerate other attributes before `name=` (e.g. react-helmet emits
// `data-rh=true name=robots content=...`) and quoted/unquoted attribute
// values. The key signal is `name=robots` + `content` containing `noindex`.
const NOINDEX_META_RE = /<meta[^>]*\bname=["']?robots["']?[^>]*\bcontent=["'][^"']*noindex/i;

async function validateFile(htmlPath) {
const html = await readFile(htmlPath, 'utf8');
const isNoindex = NOINDEX_META_RE.test(html);
const errors = [];
const warnings = [];
let count = 0;
Expand All @@ -209,7 +220,7 @@ async function validateFile(htmlPath) {
}
const r = validatePayload(parsed, `${htmlPath} script #${count}`);
errors.push(...r.errors);
warnings.push(...r.warnings);
if (!isNoindex) warnings.push(...r.warnings);
}
return { count, errors, warnings };
}
Expand Down
21 changes: 21 additions & 0 deletions src/data/transcript-inheritance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$comment": "Auto-generated by scripts/generate-transcript-inheritance.mjs (prebuild step). Edits will be overwritten. Maps transcript permalink → { about, mentions } from the parent landing page's frontmatter so video-seo.tsx can inherit entity refs.",
"generated_at": "2026-06-06T22:00:12.383Z",
"entries": {
"/community/2026-05-27-community-meeting-transcript/": {
"about": "wasmCloud",
"mentions": [
"ComponentModel",
"WASIPreview3",
"WASI",
"JCO",
"ComponentizeJS",
"StarlingMonkey",
"Wasmtime",
"BytecodeAlliance",
"Java",
"JavaScript"
]
}
}
}
5 changes: 4 additions & 1 deletion src/pages/_index/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ const softwareApplicationSchema = {
'wasmCloud is an open source CNCF project that enables teams to build, manage, and scale polyglot Wasm applications across any cloud, Kubernetes, or edge.',
url: 'https://wasmcloud.com',
downloadUrl: 'https://wasmcloud.com/docs/installation/',
softwareHelp: 'https://wasmcloud.com/docs/',
softwareHelp: {
'@type': 'CreativeWork',
url: 'https://wasmcloud.com/docs/',
},
license: 'https://www.apache.org/licenses/LICENSE-2.0',
isAccessibleForFree: true,
author: { '@id': 'https://wasmcloud.com/#organization' },
Expand Down
37 changes: 34 additions & 3 deletions src/theme/wasmcloud/blog/blog-post-schema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import {
buildEntityNodes,
buildEntityRefs,
} from '@theme/wasmcloud/structured-data/entities';
// Community transcript pages inherit about/mentions from their parent
// landing page via this map (see video-seo.tsx + the prebuild generator).
// On non-community pages and on landing pages the map is unused.
import transcriptInheritance from '@site/src/data/transcript-inheritance.json';

type InheritedRefs = { about?: string; mentions?: string[] };
const TRANSCRIPT_INHERITANCE: Record<string, InheritedRefs> =
(transcriptInheritance as { entries?: Record<string, InheritedRefs> }).entries ?? {};

/**
* Per M2 of the structured-data spike: emit the full Article-family schema
Expand Down Expand Up @@ -187,8 +195,26 @@ export default function BlogPostSchema(): JSX.Element | null {
metadata,
siteUrl,
);
const entityRefs = buildEntityRefs(frontMatter as Record<string, unknown>);
const entityNodes = buildEntityNodes(frontMatter as Record<string, unknown>);
// Community transcript pages don't carry their own about/mentions
// (those refs live on the parent landing page). Inherit from the
// prebuild-generated map so the transcript's BlogPosting/Article
// node carries the same entity graph as its meeting page.
const permalinkWithSlash = permalink.endsWith('/') ? permalink : permalink + '/';
const inherited =
TRANSCRIPT_INHERITANCE[permalink] || TRANSCRIPT_INHERITANCE[permalinkWithSlash];
const fmForRefs: Record<string, unknown> = inherited
? {
...(frontMatter as Record<string, unknown>),
...(((frontMatter as { about?: unknown }).about === undefined &&
inherited.about) ? { about: inherited.about } : {}),
...(!Array.isArray((frontMatter as { mentions?: unknown }).mentions) &&
inherited.mentions
? { mentions: inherited.mentions }
: {}),
}
: (frontMatter as Record<string, unknown>);
const entityRefs = buildEntityRefs(fmForRefs);
const entityNodes = buildEntityNodes(fmForRefs);

// M2 risk #12: Speakable is restricted to NewsArticle. Honor the
// `speakable: true` frontmatter only when the post is typed NewsArticle.
Expand All @@ -212,7 +238,12 @@ export default function BlogPostSchema(): JSX.Element | null {
publisher: PUBLISHER_REF,
...(datePublished && { datePublished }),
...(dateModified && { dateModified }),
...(authors && { author: authors }),
// `author` is technically optional on Article-family schemas, but
// Google's Rich Results Test flags its absence on every Article it
// sees. Community transcript pages don't carry personal `authors:`
// in frontmatter — for those we fall back to the wasmCloud project
// org so the field is always present and the rich-result is eligible.
author: authors ?? PUBLISHER_REF,
...(image && { image }),
...(keywords && { keywords }),
...(wordCount !== undefined && { wordCount }),
Expand Down
10 changes: 8 additions & 2 deletions src/theme/wasmcloud/community/post-page/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import Head from '@docusaurus/Head';
import { HtmlClassNameProvider, ThemeClassNames } from '@docusaurus/theme-common';
import { BlogPostProvider, useBlogPost } from '@docusaurus/plugin-content-blog/client';
import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata';
import BlogPostPageStructuredData from '@theme/BlogPostPage/StructuredData';
import TOC from '@theme/TOC';
import type { Props } from '@theme/BlogPostPage';
import Unlisted from '@theme/ContentVisibility/Unlisted';
Expand All @@ -14,6 +13,13 @@ import CommunityPostItem from '../post-item';
import Link from '@docusaurus/Link';
import VideoSEO from '../video-seo';
import MeetingSpeakers from '../meeting-speakers';
// Replaces Docusaurus's default `<BlogPostPageStructuredData />` with our
// richer BlogPostSchema so community pages emit the same Article-family
// payload (with author/publisher defaulted to the wasmCloud org, an image
// derived from frontmatter, and entity-graph mentions) as the blog. Without
// this, the default emits `author: []` on transcript pages and Google's
// Rich Results Test reports "Missing field 'author'".
import BlogPostSchema from '../../blog/blog-post-schema';

// Community meetings & transcripts older than 2025 are no longer current. Tell
// search engines not to index them, but keep `follow` so they still pass link
Expand Down Expand Up @@ -87,7 +93,7 @@ export default function CommunityPostPage(props: Props): JSX.Element {
className={clsx(ThemeClassNames.wrapper.blogPages, ThemeClassNames.page.blogPostPage)}
>
<BlogPostPageMetadata />
<BlogPostPageStructuredData />
<BlogPostSchema />
<NoindexIfArchived />
<CommunityPostPageSEO />
<CommunityPostPageContent>
Expand Down
60 changes: 50 additions & 10 deletions src/theme/wasmcloud/community/video-seo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ import Head from '@docusaurus/Head';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type { BlogPostMetadata } from '@docusaurus/plugin-content-blog';
import speakersData from '@site/src/data/speakers.json';
// Auto-generated at prebuild time by scripts/generate-transcript-inheritance.mjs.
// Maps each transcript permalink to its parent landing page's about/mentions
// frontmatter so transcript Article JSON-LD inherits the same entity graph
// without the author having to duplicate the refs across both files.
import transcriptInheritance from '@site/src/data/transcript-inheritance.json';
import JsonLd from '@theme/wasmcloud/json-ld';
import { buildEntityRefs } from '@theme/wasmcloud/structured-data/entities';
import { isTranscriptPermalink } from './utils';

type InheritedRefs = { about?: string; mentions?: string[] };
const TRANSCRIPT_INHERITANCE: Record<string, InheritedRefs> =
(transcriptInheritance as { entries?: Record<string, InheritedRefs> }).entries ?? {};

type Chapter = { seconds: number; label: string };

type SpeakerPerson = {
Expand Down Expand Up @@ -162,9 +171,10 @@ function meetingUrlForTranscript(siteUrl: string, permalink: string): string {
* result as of June 2025).
*
* Transcript page (M8 — Article):
* - Article with transcribes link back to the VideoObject's @id, plus
* mentions: [Person] for speakers and about/mentions Thing entities
* from M12's dictionary.
* - Article with `associatedMedia` link to the VideoObject's @id (the
* transcript's source recording), `isPartOf` a CreativeWorkSeries for
* the wasmCloud Community Meeting collection, plus mentions: [Person]
* for speakers and about/mentions Thing entities from M12's dictionary.
*/
export default function VideoSEO({
metadata,
Expand Down Expand Up @@ -194,7 +204,29 @@ export default function VideoSEO({

const keywords = getKeywords(frontMatter);
const speakers = buildSpeakers(frontMatter);
const entityRefs = buildEntityRefs(frontMatter);
// Transcript pages typically don't repeat `about:` / `mentions:` in their
// own frontmatter — those refs live on the parent landing page. When this
// is a transcript page and its own frontmatter omits one of the refs, fall
// back to the inheritance map (generated at prebuild from landing-page
// frontmatter; see scripts/generate-transcript-inheritance.mjs). Avoids the
// M10 validator warning `no about or mentions (M12 entity refs)` on
// transcript Article nodes.
const permalinkWithSlash = permalink.endsWith('/') ? permalink : permalink + '/';
const inheritedRefs: InheritedRefs | undefined = isTranscript
? TRANSCRIPT_INHERITANCE[permalink] || TRANSCRIPT_INHERITANCE[permalinkWithSlash]
: undefined;
const frontMatterForRefs = inheritedRefs
? {
...frontMatter,
...(frontMatter.about === undefined && inheritedRefs.about
? { about: inheritedRefs.about }
: {}),
...(!Array.isArray(frontMatter.mentions) && inheritedRefs.mentions
? { mentions: inheritedRefs.mentions }
: {}),
}
: frontMatter;
const entityRefs = buildEntityRefs(frontMatterForRefs);

// Stable IDs the schemas use to cross-reference each other:
// VideoObject @id → canonical meeting URL + #video
Expand Down Expand Up @@ -327,17 +359,25 @@ export default function VideoSEO({
// personally authored, the project entity is the appropriate author.
author: VIDEO_PUBLISHER,
publisher: VIDEO_PUBLISHER,
// Article rich-result eligibility wants an `image`. The YouTube
// maxresdefault thumbnail is the canonical preview for this
// transcript (it shares its source video with the meeting page).
image: thumbnailUrl,
inLanguage: VIDEO_LANGUAGE,
url: canonicalUrl,
mainEntityOfPage: canonicalUrl,
// Link back to the canonical video entity. Google understands this
// as "this text transcribes that video" and treats the meeting page
// as the canonical AV asset.
transcribes: { '@id': videoObjectId },
// Link back to the canonical video entity via `associatedMedia` —
// a valid Article property whose range is MediaObject (VideoObject
// qualifies). Schema.org does not define a `transcribes` property,
// so we don't emit it; the prose context + the video reference
// here carry the relationship.
associatedMedia: { '@id': videoObjectId },
// Series-level grouping (every weekly meeting is part of the
// wasmCloud Community Meeting series)
// wasmCloud Community Meeting series). Use `CreativeWorkSeries`
// — schema.org has no bare `Series` type; `CreativeWorkSeries`
// is the correct CreativeWork-subtype expected by `isPartOf`.
isPartOf: {
'@type': 'Series',
'@type': 'CreativeWorkSeries',
name: 'wasmCloud Community Meeting',
url: `${siteUrl}/community/`,
},
Expand Down
Loading