Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/agent-readiness-roadmap.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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.

---

Expand Down
16 changes: 15 additions & 1 deletion docs/homepage-redesign-roadmap.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions scripts/community-feed-notifier.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
enrichNotifierItemWithLinkedPageImage,
fetchFeedItems,
fetchWithTimeout,
loadMemberFeeds,
Expand All @@ -7,6 +8,7 @@ import {
buildDiscordPayload,
buildMessage,
defaultState,
initializeNewFeedSources,
migrateStateItemIds,
readStateFromGist,
upsertStateRecord,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
27 changes: 22 additions & 5 deletions scripts/fetch-feeds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Parser from "rss-parser";
import {
fetchRawFeedItems,
fetchText,
extractImageUrl,
isHttpUrl,
isYoutubeUrl,
loadMemberFeeds,
Expand Down Expand Up @@ -375,6 +376,9 @@ export function resolveFeedImage(rawItem, source) {
}
}

const sharedImage = extractImageUrl(rawItem, source);
if (sharedImage) return sharedImage;

return null;
}

Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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),
});
}
}
Expand All @@ -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.`,
Expand Down
36 changes: 36 additions & 0 deletions scripts/lib/community-feed-notifier-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function defaultState() {
initializedAt: null,
updatedAt: null,
items: {},
sources: {},
events: {},
weeklyDigest: {},
};
Expand All @@ -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
Expand Down Expand Up @@ -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 = {};
}
Expand All @@ -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];
Expand Down
57 changes: 55 additions & 2 deletions scripts/lib/community-feed-reader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<meta\b[^>]*(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["'][^>]*>/gi),
).map((match) => match[0].match(/content=["']([^"']+)["']/i)?.[1]);
const imageTags = Array.from(value.matchAll(/<img\b[^>]*>/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),
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 || [];
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/Calendar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const eventColorClass = (event: MeetupEvent) => {
>
{event.title}
</a>
<div class="mt-2 grid gap-1 text-[0.6875rem] font-medium text-white/90">
<div class="mt-1 grid gap-0.5 text-[0.6875rem] font-medium leading-tight text-white/90">
<span
class="inline-flex items-center gap-1.5"
>
Expand All @@ -152,7 +152,7 @@ const eventColorClass = (event: MeetupEvent) => {
data-analytics-event="calendar_event_click"
data-analytics-link="maps"
data-analytics-location="desktop_calendar"
class="inline-flex min-h-11 items-center gap-1.5 underline decoration-white/50 underline-offset-2 transition hover:text-white hover:decoration-white focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
class="inline-flex items-center gap-1.5 py-1 underline decoration-white/50 underline-offset-2 transition hover:text-white hover:decoration-white focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
<FaLocationDot
className="h-3 w-3 shrink-0"
Expand Down
Loading
Loading