diff --git a/README.md b/README.md
index 4e2567c..17596c0 100644
--- a/README.md
+++ b/README.md
@@ -109,11 +109,14 @@ public/ # Files served as-is (favicon, images)
- Cloudflare Pages Functions at `/` and `/ja/` return those files only when the request explicitly accepts `text/markdown`; normal browser requests continue to receive the Astro HTML pages.
- Verify locally or against a preview with `curl -H 'Accept: text/markdown' -D - https://preview-url/`.
- The Markdown maintenance skill is published at `/.well-known/agent-skills/markdown-for-agents/SKILL.md`; `npm run agent:skills` regenerates its discovery index digest during builds.
+- The WebMCP maintenance skill is published at `/.well-known/agent-skills/webmcp-maintenance/SKILL.md`; `npm run build` verifies the Markdown, Agent Skills, and WebMCP artifacts before completing.
## Community Feed Notifier
- The notifier reads approved sources from `src/data/member-feeds.json`.
- Each feed source has a stable `id`; do not change it casually because notifier state keys are derived from it.
+- New feed sources are recorded in the gist-backed `sources` map and their existing backlog is suppressed; future items are notified without historical backfill.
+- Feed excerpts use the same 280-character normalization for the site and notifications, and notifier items can enrich missing images from linked-page metadata.
- State lives in the public gist `f95dd7597eec170d738d905e3666bfc6` as `community-feed-state.json`.
- On the first non-dry run, the notifier seeds the current backlog into the gist without posting. Use the workflow dispatch input `allow_initial_posts` if you intentionally want to announce the backlog.
- For a lightweight demo, use the workflow dispatch input `demo_mode`. It posts at most 3 items and seeds the rest of the current backlog so later scheduled runs do not replay the full backlog.
diff --git a/docs/agent-readiness-roadmap.md b/docs/agent-readiness-roadmap.md
index cc059c1..54bdb44 100644
--- a/docs/agent-readiness-roadmap.md
+++ b/docs/agent-readiness-roadmap.md
@@ -1,6 +1,6 @@
# Agent Readiness Roadmap
-Status: Proposed — Link headers are implemented separately in PR 107
+Status: Core work complete — PRs 107–112 merged; DNSSEC and authoring workflows deferred
## Purpose
@@ -20,7 +20,7 @@ DNS-AID remains deferred until the site has a real agent endpoint or capability
- Link headers advertising the sitemap and security contact are implemented in PR 107.
- The site is a static Astro build deployed to Cloudflare Pages.
- Meetup events and member feeds are fetched at build time and rendered from committed JSON snapshots.
-- There is no public API, OAuth provider, MCP server, authenticated service, or agent skill registry.
+- There is no public API, OAuth provider, MCP server, or authenticated service. The Agent Skills index and WebMCP read-only tools are live.
- Cloudflare’s managed Markdown for Agents feature may not be available on the zone’s current Free plan.
## Delivery order
@@ -32,7 +32,7 @@ DNS-AID remains deferred until the site has a real agent endpoint or capability
| C | DNSSEC activation | Registrar access at Hover | Cloudflare DNS answers are cryptographically authenticated |
| D | DNS-AID evaluation | A real agent endpoint or capability descriptor | Decide whether an SVCB/HTTPS record would describe a genuine service |
-PRs A and C can proceed independently. PR B should not begin until the event/feed JSON contract is confirmed and its read-only footprint is reviewed.
+PRs A and B are complete. DNSSEC remains an independent infrastructure project, and WebMCP author/content actions remain deferred until explicit organizer approval and a separate permission review.
---
diff --git a/docs/homepage-redesign-roadmap.md b/docs/homepage-redesign-roadmap.md
index fa4443e..5b77c93 100644
--- a/docs/homepage-redesign-roadmap.md
+++ b/docs/homepage-redesign-roadmap.md
@@ -1,6 +1,6 @@
# Homepage Redesign Roadmap
-Status: Active — PRs 1–7 and 4A merged; PR 8 final QA in review
+Status: Core redesign complete — PRs 1–8 merged; closeout polish and feed operations remain
Primary audience: People considering their first Kyoto Tech Meetup
Secondary audience: Existing community members looking for events, venue details, conversations, and member work
@@ -68,6 +68,20 @@ Final invitation and footer
PR 1 and PR 2 can be developed in parallel. Later PRs should follow the dependency order above.
+## Closeout work
+
+The core redesign is complete, including the member milestone and the shared “happening now” state across the hero, mobile event list, and desktop calendar. The remaining work is intentionally split into small operational and polish PRs:
+
+| PR | Workstream | Depends on | Primary outcome |
+| --- | --- | --- | --- |
+| 9 | Event detail UI polish | 4A and 6 | Align Maps metadata and remove excess calendar spacing |
+| 10 | Shared feed content contract | Existing feed and notifier readers | Keep excerpts, URLs, attribution, and image selection consistent |
+| 11 | YouTube reliability and stale fallback | 10 | Retry transient YouTube failures and preserve each source’s last good snapshot |
+| 12 | Discord source onboarding | Existing gist-backed notifier state | Prevent historical backfill when a feed is newly added |
+| 13 | Final QA and documentation | 9–12 | Verify the full site/feed/notification workflow and close the roadmap |
+
+Keep DNSSEC and any future WebMCP authoring workflows outside this closeout sequence; they remain separate, optional initiatives.
+
---
## PR 1: Locale and navigation foundations
diff --git a/scripts/community-feed-notifier.mjs b/scripts/community-feed-notifier.mjs
index ff81e7f..59db85a 100644
--- a/scripts/community-feed-notifier.mjs
+++ b/scripts/community-feed-notifier.mjs
@@ -1,4 +1,5 @@
import {
+ enrichNotifierItemWithLinkedPageImage,
fetchFeedItems,
fetchWithTimeout,
loadMemberFeeds,
@@ -7,6 +8,7 @@ import {
buildDiscordPayload,
buildMessage,
defaultState,
+ initializeNewFeedSources,
migrateStateItemIds,
readStateFromGist,
upsertStateRecord,
@@ -228,7 +230,10 @@ async function main() {
maxItemsPerFeed: args.maxItemsPerFeed,
userAgent: USER_AGENT,
});
- allItems.push(...items);
+ const enrichedItems = await Promise.all(
+ items.map((item) => enrichNotifierItemWithLinkedPageImage(item)),
+ );
+ allItems.push(...enrichedItems);
} catch (error) {
fetchFailures.push({
source: source.name,
@@ -257,6 +262,7 @@ async function main() {
(a, b) => new Date(a.publishedAt).valueOf() - new Date(b.publishedAt).valueOf(),
);
const isFirstRun = Object.keys(state.items).length === 0;
+ const newlyInitializedSources = initializeNewFeedSources(state, memberFeeds, now);
if (isFirstRun && !args.allowInitialPosts) {
for (const item of sortedItems) {
@@ -284,7 +290,7 @@ async function main() {
const deliveryFailures = [];
let limitedItems = 0;
let processedItems = 0;
- let stateChanged = migration.changed;
+ let stateChanged = migration.changed || newlyInitializedSources.size > 0;
if (stateChanged) {
state.initializedAt = state.initializedAt || now;
@@ -303,6 +309,12 @@ async function main() {
for (const item of sortedItems) {
const record = upsertStateRecord(state, item, now);
+ if (!args.allowInitialPosts && newlyInitializedSources.has(item.source.id)) {
+ record.suppressed = true;
+ stateChanged = true;
+ continue;
+ }
+
if (record.suppressed) continue;
if (!hasPendingDestinations(record, destinations)) continue;
@@ -342,6 +354,10 @@ async function main() {
});
}
+ if (stateChanged && !args.dryRun && newDeliveries === 0) {
+ await writeStateToGist(gistId, gistToken, state, GIST_OPTIONS);
+ }
+
if (!stateChanged) {
console.log("[notifier] No new community posts needed delivery.");
} else if (newDeliveries === 0) {
diff --git a/scripts/fetch-feeds.mjs b/scripts/fetch-feeds.mjs
index 5386f6b..137d10d 100644
--- a/scripts/fetch-feeds.mjs
+++ b/scripts/fetch-feeds.mjs
@@ -6,6 +6,7 @@ import Parser from "rss-parser";
import {
fetchRawFeedItems,
fetchText,
+ extractImageUrl,
isHttpUrl,
isYoutubeUrl,
loadMemberFeeds,
@@ -375,6 +376,9 @@ export function resolveFeedImage(rawItem, source) {
}
}
+ const sharedImage = extractImageUrl(rawItem, source);
+ if (sharedImage) return sharedImage;
+
return null;
}
@@ -429,7 +433,7 @@ function normalizeItem(rawItem, source) {
siteUrl: source.siteUrl,
feedUrl: source.feedUrl,
},
- summary: truncate(summary, 360),
+ summary: truncate(summary),
image: resolveFeedImage(rawItem, source),
inlineImage: resolveInlineContentImage(rawItem, source),
};
@@ -480,9 +484,18 @@ async function readExisting(filePath) {
}
}
+export function getCachedFeedForSource(existing, source) {
+ return existing?.feeds?.find((feed) =>
+ feed.feedUrl === source.feedUrl ||
+ feed.siteUrl === source.siteUrl ||
+ feed.name === source.name,
+ ) ?? null;
+}
+
async function main() {
const args = parseArgs(process.argv.slice(2));
const memberFeeds = await loadMemberFeeds();
+ const existing = args.staleOk ? await readExisting(args.outputPath) : null;
const parser = new Parser();
const now = new Date();
const failures = [];
@@ -505,19 +518,24 @@ async function main() {
);
feedsWithItems.push({
+ id: source.id,
name: source.name,
siteUrl: source.siteUrl,
feedUrl: source.feedUrl,
items: itemsWithLinkedPageImages,
});
} catch (error) {
- failures.push({ source: source.name, error: error?.message || String(error) });
+ const message = error?.message || String(error);
+ failures.push({ source: source.name, error: message });
+ const cached = getCachedFeedForSource(existing, source);
feedsWithItems.push({
+ id: source.id,
name: source.name,
siteUrl: source.siteUrl,
feedUrl: source.feedUrl,
- items: [],
- error: error?.message || String(error),
+ items: args.staleOk && Array.isArray(cached?.items) ? cached.items : [],
+ error: message,
+ usedFallback: Boolean(args.staleOk && cached?.items?.length),
});
}
}
@@ -535,7 +553,6 @@ async function main() {
};
if (totalItems === 0 && args.staleOk) {
- const existing = await readExisting(args.outputPath);
if (existing?.feeds?.length) {
console.warn(
`[feeds] Using existing data from ${args.outputPath} because fetching produced no items.`,
diff --git a/scripts/lib/community-feed-notifier-state.mjs b/scripts/lib/community-feed-notifier-state.mjs
index 2e19983..70b68ba 100644
--- a/scripts/lib/community-feed-notifier-state.mjs
+++ b/scripts/lib/community-feed-notifier-state.mjs
@@ -90,6 +90,7 @@ export function defaultState() {
initializedAt: null,
updatedAt: null,
items: {},
+ sources: {},
events: {},
weeklyDigest: {},
};
@@ -111,6 +112,10 @@ export function parseState(content) {
parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items)
? parsed.items
: {},
+ sources:
+ parsed.sources && typeof parsed.sources === "object" && !Array.isArray(parsed.sources)
+ ? parsed.sources
+ : {},
events:
parsed.events && typeof parsed.events === "object" && !Array.isArray(parsed.events)
? parsed.events
@@ -206,6 +211,9 @@ export function migrateStateItemIds(state, sources) {
if (!state.events || typeof state.events !== "object" || Array.isArray(state.events)) {
state.events = {};
}
+ if (!state.sources || typeof state.sources !== "object" || Array.isArray(state.sources)) {
+ state.sources = {};
+ }
if (!state.weeklyDigest || typeof state.weeklyDigest !== "object" || Array.isArray(state.weeklyDigest)) {
state.weeklyDigest = {};
}
@@ -220,6 +228,34 @@ export function migrateStateItemIds(state, sources) {
};
}
+export function initializeNewFeedSources(state, sources, initializedAt) {
+ const newlyInitialized = new Set();
+ state.sources = state.sources && typeof state.sources === "object" ? state.sources : {};
+ const knownSourceIds = new Set(
+ Object.values(state.items || {})
+ .map((item) => item?.source?.id)
+ .filter(Boolean),
+ );
+
+ for (const source of sources) {
+ if (state.sources[source.id]) continue;
+ if (knownSourceIds.has(source.id)) {
+ state.sources[source.id] = {
+ initializedAt: state.initializedAt || initializedAt,
+ feedUrl: source.feedUrl,
+ };
+ continue;
+ }
+ state.sources[source.id] = {
+ initializedAt,
+ feedUrl: source.feedUrl,
+ };
+ newlyInitialized.add(source.id);
+ }
+
+ return newlyInitialized;
+}
+
export function upsertStateRecord(state, item, seenAt, options = {}) {
const legacyId = buildLegacyStateItemId(item.source, item.sourceItemId);
const existing = state.items[item.id] || state.items[legacyId];
diff --git a/scripts/lib/community-feed-reader.mjs b/scripts/lib/community-feed-reader.mjs
index ff87ddd..32e66e3 100644
--- a/scripts/lib/community-feed-reader.mjs
+++ b/scripts/lib/community-feed-reader.mjs
@@ -196,7 +196,36 @@ function htmlImageUrl(value, baseUrl) {
}
}
-function extractImageUrl(rawItem, source) {
+function resolveHtmlImageUrl(value, baseUrl) {
+ if (!value || typeof value !== "string") return "";
+ const metaCandidates = Array.from(
+ value.matchAll(/]*(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["'][^>]*>/gi),
+ ).map((match) => match[0].match(/content=["']([^"']+)["']/i)?.[1]);
+ const imageTags = Array.from(value.matchAll(/]*>/gi)).map(
+ (match) => match[0],
+ );
+ const featuredCandidates = imageTags
+ .filter((tag) => /class=["'][^"']*wp-post-image[^"']*["']/i.test(tag))
+ .map((tag) => tag.match(/(?:src|data-src)=["']([^"']+)["']/i)?.[1]);
+ const fallbackCandidates = imageTags
+ .map((tag) => tag.match(/(?:src|data-src)=["']([^"']+)["']/i)?.[1])
+ .filter(Boolean);
+ const candidates = [...metaCandidates, ...featuredCandidates, ...fallbackCandidates];
+
+ for (const candidate of candidates) {
+ if (!candidate) continue;
+ try {
+ const absoluteUrl = new URL(candidate, baseUrl).toString();
+ if (isHttpUrl(absoluteUrl)) return absoluteUrl;
+ } catch {
+ // Try the next candidate.
+ }
+ }
+
+ return "";
+}
+
+export function extractImageUrl(rawItem, source) {
const baseUrl = rawItem.link || source.siteUrl;
const candidates = [
enclosureImageUrl(rawItem.enclosure),
@@ -213,6 +242,7 @@ function extractImageUrl(rawItem, source) {
];
for (const candidate of candidates) {
+ if (!candidate) continue;
try {
const absoluteUrl = new URL(candidate, baseUrl).toString();
if (isHttpUrl(absoluteUrl)) return absoluteUrl;
@@ -286,6 +316,23 @@ export function normalizeNotifierItem(rawItem, source) {
};
}
+export async function enrichNotifierItemWithLinkedPageImage(
+ item,
+ { fetchTextFn = fetchText } = {},
+) {
+ if (item.imageUrl || !isHttpUrl(item.link)) return item;
+
+ try {
+ const html = await fetchTextFn(item.link);
+ return {
+ ...item,
+ imageUrl: resolveHtmlImageUrl(html, item.link) || null,
+ };
+ } catch {
+ return item;
+ }
+}
+
export function normalizeAndLimitFeedItems(
rawItems,
source,
@@ -325,7 +372,13 @@ export async function fetchRawFeedItems(
feedTimeoutMs,
userAgent,
});
- const xml = await fetchTextFn(resolvedFeedUrl, {}, feedTimeoutMs, userAgent);
+ let xml;
+ try {
+ xml = await fetchTextFn(resolvedFeedUrl, {}, feedTimeoutMs, userAgent);
+ } catch (error) {
+ if (!isYoutubeUrl(resolvedFeedUrl)) throw error;
+ xml = await fetchTextFn(resolvedFeedUrl, {}, feedTimeoutMs, userAgent);
+ }
const parsed = await parser.parseString(xml);
return parsed?.items || [];
}
diff --git a/src/components/Calendar.astro b/src/components/Calendar.astro
index 33543a1..465090a 100644
--- a/src/components/Calendar.astro
+++ b/src/components/Calendar.astro
@@ -126,7 +126,7 @@ const eventColorClass = (event: MeetupEvent) => {
>
{event.title}
-