refactor(where): read local checkins from content

This commit is contained in:
svemagie
2026-03-08 15:47:10 +01:00
parent 85082510dd
commit c7a30102b7
7 changed files with 369 additions and 412 deletions
+173 -190
View File
@@ -1,33 +1,43 @@
/** /**
* Where/Checkin data * Where/Checkin data
* *
* Fetches h-entry checkins from an OwnYourSwarm-connected endpoint. * Reads local check-ins created by this site's Micropub endpoint.
* Expected payload: MF2 JSON with h-entry objects containing `checkin` and/or `location`. * A post is treated as a check-in when frontmatter includes checkin/location
* metadata, coordinates, or a checkin-like category.
*/ */
import EleventyFetch from "@11ty/eleventy-fetch"; import matter from "gray-matter";
import { readdirSync, readFileSync } from "node:fs";
import { extname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const FEED_URL = process.env.OWNYOURSWARM_FEED_URL || "https://ownyourswarm.p3k.io/"; const CONTENT_DIR = fileURLToPath(new URL("../content", import.meta.url));
const FEED_TOKEN = process.env.OWNYOURSWARM_FEED_TOKEN || "";
function first(value) { function first(value) {
if (Array.isArray(value)) return value[0]; if (Array.isArray(value)) return value[0];
return value; return value;
} }
function asArray(value) {
if (value === null || value === undefined || value === "") return [];
return Array.isArray(value) ? value : [value];
}
function asText(value) { function asText(value) {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
if (typeof value === "string") return value; if (typeof value === "string") return value;
if (typeof value === "number") return String(value); if (typeof value === "number") return String(value);
if (value instanceof Date) return value.toISOString();
if (typeof value === "object") { if (typeof value === "object") {
if (typeof value.value === "string") return value.value; if (typeof value.value === "string") return value.value;
if (typeof value.text === "string") return value.text; if (typeof value.text === "string") return value.text;
if (typeof value.url === "string") return value.url;
} }
return ""; return "";
} }
function asNumber(value) { function asNumber(value) {
const raw = first(value); const raw = first(asArray(value));
const num = Number(raw); const num = Number(raw);
return Number.isFinite(num) ? num : null; return Number.isFinite(num) ? num : null;
} }
@@ -36,120 +46,41 @@ function joinLocation(locality, region, country) {
return [locality, region, country].filter(Boolean).join(", "); return [locality, region, country].filter(Boolean).join(", ");
} }
function buildCandidateUrls(baseUrl) {
const raw = (baseUrl || "").trim();
if (!raw) return [];
const urls = [raw];
try {
const parsed = new URL(raw);
const pathWithoutSlash = parsed.pathname.replace(/\/$/, "");
const basePath = `${parsed.origin}${pathWithoutSlash}`;
const withFormat = new URL(parsed.toString());
withFormat.searchParams.set("format", "json");
urls.push(withFormat.toString());
const withOutput = new URL(parsed.toString());
withOutput.searchParams.set("output", "json");
urls.push(withOutput.toString());
if (pathWithoutSlash) {
urls.push(`${basePath}.json`);
urls.push(`${basePath}/checkins.json`);
urls.push(`${basePath}/feed.json`);
urls.push(`${basePath}/api/checkins`);
} else {
urls.push(`${parsed.origin}/checkins.json`);
urls.push(`${parsed.origin}/feed.json`);
urls.push(`${parsed.origin}/api/checkins`);
}
} catch {
// If URL parsing fails, we still try the raw URL above.
}
return [...new Set(urls)];
}
async function fetchJson(url) {
const headers = FEED_TOKEN ? { Authorization: `Bearer ${FEED_TOKEN}` } : {};
const fetchOptions = Object.keys(headers).length ? { headers } : undefined;
try {
return await EleventyFetch(url, {
duration: "15m",
type: "json",
fetchOptions,
});
} catch (jsonError) {
// Some endpoints serve JSON with an incorrect content type. Retry as text.
const text = await EleventyFetch(url, {
duration: "15m",
type: "text",
fetchOptions,
});
const trimmed = text.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
throw jsonError;
}
return JSON.parse(trimmed);
}
}
function looksLikeCheckinEntry(entry) {
if (!entry || typeof entry !== "object") return false;
const type = Array.isArray(entry.type) ? entry.type : [];
if (type.includes("h-entry")) {
const props = entry.properties || {};
return Boolean(props.checkin || props.location);
}
return false;
}
function extractEntries(payload) {
const queue = [];
if (Array.isArray(payload)) {
queue.push(...payload);
} else if (payload && typeof payload === "object") {
queue.push(payload);
}
const entries = [];
while (queue.length) {
const item = queue.shift();
if (!item || typeof item !== "object") continue;
if (looksLikeCheckinEntry(item)) {
entries.push(item);
}
if (Array.isArray(item.items)) queue.push(...item.items);
if (Array.isArray(item.children)) queue.push(...item.children);
if (item.data && Array.isArray(item.data.items)) queue.push(...item.data.items);
}
return entries;
}
function uniqueStrings(values) { function uniqueStrings(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
function toRelativePath(filePath) {
return relative(CONTENT_DIR, filePath).replace(/\\/g, "/");
}
function walkMarkdownFiles(dirPath) {
const files = [];
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...walkMarkdownFiles(fullPath));
continue;
}
if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") {
files.push(fullPath);
}
}
return files;
}
function parsePersonCard(card) { function parsePersonCard(card) {
if (!card || typeof card !== "object") return null; if (!card || typeof card !== "object") return null;
const props = card.properties || {}; const props = card.properties || {};
const urls = Array.isArray(props.url) const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean);
? props.url.map((url) => asText(url)).filter(Boolean) const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean);
: [];
const photos = Array.isArray(props.photo)
? props.photo.map((photo) => asText(photo)).filter(Boolean)
: [];
return { return {
name: asText(first(props.name)), name: asText(first(asArray(props.name))),
url: urls[0] || "", url: urls[0] || "",
urls, urls,
photo: photos[0] || "", photo: photos[0] || "",
@@ -168,6 +99,7 @@ function parseCategory(categoryValues) {
if (!value || typeof value !== "object") continue; if (!value || typeof value !== "object") continue;
const type = Array.isArray(value.type) ? value.type : []; const type = Array.isArray(value.type) ? value.type : [];
if (type.includes("h-card")) { if (type.includes("h-card")) {
const person = parsePersonCard(value); const person = parsePersonCard(value);
if (person && (person.name || person.url)) { if (person && (person.name || person.url)) {
@@ -176,70 +108,114 @@ function parseCategory(categoryValues) {
} }
} }
const normalizedTags = uniqueStrings(tags).filter(
(tag) => !["where", "slashpage"].includes(tag.toLowerCase())
);
return { return {
tags: uniqueStrings(tags), tags: normalizedTags,
people, people,
}; };
} }
function normalizeCheckin(entry) { function isCheckinFrontmatter(frontmatter, relativePath) {
const props = entry.properties || {}; if (relativePath === "pages/where.md") return false;
if (!props.checkin && !props.location) { const categories = asArray(frontmatter.category)
return null; .map((value) => asText(value).toLowerCase())
.filter(Boolean);
const hasCheckinField = frontmatter.checkin !== undefined || frontmatter["check-in"] !== undefined;
const hasLocationField = frontmatter.location !== undefined;
const hasCoordinates = frontmatter.latitude !== undefined || frontmatter.longitude !== undefined;
const hasCheckinCategory = categories.includes("where") || categories.includes("checkin") || categories.includes("swarm");
return hasCheckinField || hasLocationField || hasCoordinates || hasCheckinCategory;
} }
const checkinCard = first(props.checkin); function normalizeCheckin(frontmatter, relativePath) {
const locationCard = first(props.location); const checkinValue = first(asArray(frontmatter.checkin ?? frontmatter["check-in"]));
const locationValue = first(asArray(frontmatter.location));
const checkinProps = const checkinProps =
checkinCard && typeof checkinCard === "object" && checkinCard.properties checkinValue && typeof checkinValue === "object" && checkinValue.properties
? checkinCard.properties ? checkinValue.properties
: {}; : {};
const locationProps = const locationProps =
locationCard && typeof locationCard === "object" && locationCard.properties locationValue && typeof locationValue === "object" && locationValue.properties
? locationCard.properties ? locationValue.properties
: {}; : {};
const venueUrlsRaw = Array.isArray(checkinProps.url) const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean);
? checkinProps.url const venueUrlFromSimpleMode = typeof checkinValue === "string" ? checkinValue : "";
: checkinProps.url const venueUrls = venueUrlFromSimpleMode
? [checkinProps.url] ? [venueUrlFromSimpleMode, ...venueUrlsFromCard]
: []; : venueUrlsFromCard;
const venueUrls = venueUrlsRaw.map((url) => asText(url)).filter(Boolean);
const name = asText(first(checkinProps.name)) || "Unknown place"; const venueUrl = venueUrls[0] || "";
const venueUrl = venueUrls[0] || asText(checkinCard?.value);
const venueWebsiteUrl = venueUrls[1] || ""; const venueWebsiteUrl = venueUrls[1] || "";
const venueSocialUrl = venueUrls[2] || ""; const venueSocialUrl = venueUrls[2] || "";
const locality = asText(first(checkinProps.locality)) || asText(first(locationProps.locality)); const name =
const region = asText(first(checkinProps.region)) || asText(first(locationProps.region)); asText(first(asArray(checkinProps.name))) ||
asText(frontmatter.title) ||
"Unknown place";
const locality =
asText(first(asArray(checkinProps.locality))) ||
asText(first(asArray(locationProps.locality))) ||
asText(frontmatter.locality);
const region =
asText(first(asArray(checkinProps.region))) ||
asText(first(asArray(locationProps.region))) ||
asText(frontmatter.region);
const country = const country =
asText(first(checkinProps["country-name"])) || asText(first(asArray(checkinProps["country-name"]))) ||
asText(first(locationProps["country-name"])); asText(first(asArray(locationProps["country-name"]))) ||
asText(frontmatter["country-name"]);
const postalCode = const postalCode =
asText(first(checkinProps["postal-code"])) || asText(first(asArray(checkinProps["postal-code"]))) ||
asText(first(locationProps["postal-code"])); asText(first(asArray(locationProps["postal-code"]))) ||
asText(frontmatter["postal-code"]);
const latitude = const latitude =
asNumber(checkinProps.latitude) ?? asNumber(locationProps.latitude) ?? asNumber(props.latitude); asNumber(checkinProps.latitude) ??
asNumber(locationProps.latitude) ??
asNumber(frontmatter.latitude);
const longitude = const longitude =
asNumber(checkinProps.longitude) ?? asNumber(locationProps.longitude) ?? asNumber(props.longitude); asNumber(checkinProps.longitude) ??
asNumber(locationProps.longitude) ??
asNumber(frontmatter.longitude);
const published = asText(first(props.published)); const published =
const syndication = asText(first(props.syndication)); asText(first(asArray(frontmatter.published))) ||
const visibility = asText(first(props.visibility)).toLowerCase(); asText(frontmatter.date);
const categoryValues = Array.isArray(props.category) ? props.category : []; const syndicationUrls = asArray(frontmatter.syndication)
.map((url) => asText(url))
.filter(Boolean);
const syndication =
syndicationUrls.find((url) => url.includes("swarmapp.com")) ||
syndicationUrls[0] ||
"";
const visibility = asText(frontmatter.visibility).toLowerCase();
const categoryValues = asArray(frontmatter.category);
const category = parseCategory(categoryValues); const category = parseCategory(categoryValues);
const checkedInByCard = first(props["checked-in-by"]); const checkedInByValue = first(asArray(frontmatter["checked-in-by"] ?? frontmatter.checkedInBy));
const checkedInBy = parsePersonCard(checkedInByCard); const checkedInBy = parsePersonCard(checkedInByValue);
const photos = Array.isArray(props.photo) const photos = asArray(frontmatter.photo)
? props.photo.map((photo) => asText(photo)).filter(Boolean) .map((photo) => {
: []; if (typeof photo === "string") return photo;
if (photo && typeof photo === "object") {
return asText(photo.url || photo.value || photo.src || "");
}
return "";
})
.filter(Boolean);
const mapUrl = const mapUrl =
latitude !== null && longitude !== null latitude !== null && longitude !== null
@@ -253,10 +229,12 @@ function normalizeCheckin(entry) {
const locationText = joinLocation(locality, region, country); const locationText = joinLocation(locality, region, country);
const timestamp = published ? Date.parse(published) || 0 : 0; const timestamp = published ? Date.parse(published) || 0 : 0;
const id = syndication || `${published}-${name}-${coordinatesText}`; const permalink = asText(frontmatter.permalink);
const id = syndication || permalink || `${relativePath}-${published || "unknown"}`;
return { return {
id, id,
sourcePath: relativePath,
published, published,
timestamp, timestamp,
syndication, syndication,
@@ -282,16 +260,14 @@ function normalizeCheckin(entry) {
}; };
} }
function normalizeCheckins(entries) { function normalizeCheckins(items) {
const seen = new Set(); const seen = new Set();
const checkins = []; const checkins = [];
for (const entry of entries) { for (const item of items) {
const normalized = normalizeCheckin(entry); if (seen.has(item.id)) continue;
if (!normalized) continue; seen.add(item.id);
if (seen.has(normalized.id)) continue; checkins.push(item);
seen.add(normalized.id);
checkins.push(normalized);
} }
return checkins.sort((a, b) => b.timestamp - a.timestamp); return checkins.sort((a, b) => b.timestamp - a.timestamp);
@@ -299,28 +275,58 @@ function normalizeCheckins(entries) {
export default async function () { export default async function () {
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
const candidateUrls = buildCandidateUrls(FEED_URL);
const errors = []; const errors = [];
for (const url of candidateUrls) { let filePaths = [];
try {
console.log(`[whereCheckins] Fetching: ${url}`);
const payload = await fetchJson(url);
const entries = extractEntries(payload);
const checkins = normalizeCheckins(entries);
if (checkins.length > 0) { try {
filePaths = walkMarkdownFiles(CONTENT_DIR);
} catch (error) {
const message = `[whereCheckins] Unable to scan local content: ${error.message}`;
console.log(message);
return {
source: "local-endpoint",
available: false,
checkedAt,
scannedFiles: 0,
checkins: [],
errors: [message],
stats: {
total: 0,
withCoordinates: 0,
},
};
}
const items = [];
for (const filePath of filePaths) {
const relativePath = toRelativePath(filePath);
try {
const raw = readFileSync(filePath, "utf-8");
const frontmatter = matter(raw).data || {};
if (!isCheckinFrontmatter(frontmatter, relativePath)) continue;
const checkin = normalizeCheckin(frontmatter, relativePath);
items.push(checkin);
} catch (error) {
errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`);
}
}
const checkins = normalizeCheckins(items);
const withCoordinates = checkins.filter( const withCoordinates = checkins.filter(
(item) => item.latitude !== null && item.longitude !== null (item) => item.latitude !== null && item.longitude !== null
).length; ).length;
return { return {
feedUrl: url, source: "local-endpoint",
checkins, available: checkins.length > 0,
source: "ownyourswarm",
available: true,
checkedAt, checkedAt,
triedUrls: candidateUrls, scannedFiles: filePaths.length,
checkins,
errors, errors,
stats: { stats: {
total: checkins.length, total: checkins.length,
@@ -328,26 +334,3 @@ export default async function () {
}, },
}; };
} }
errors.push(`No checkin h-entry objects found at ${url}`);
} catch (error) {
const message = `[whereCheckins] Unable to use ${url}: ${error.message}`;
console.log(message);
errors.push(message);
}
}
return {
feedUrl: FEED_URL,
checkins: [],
source: "unavailable",
available: false,
checkedAt,
triedUrls: candidateUrls,
errors,
stats: {
total: 0,
withCoordinates: 0,
},
};
}
+3 -8
View File
@@ -8,7 +8,7 @@ withSidebar: true
<header class="mb-6 sm:mb-8"> <header class="mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ title or "Where" }}</h1> <h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ title or "Where" }}</h1>
<p class="text-surface-600 dark:text-surface-400"> <p class="text-surface-600 dark:text-surface-400">
Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. Recent check-ins captured by this site via Micropub.
</p> </p>
{% if content %} {% if content %}
<div class="prose prose-surface dark:prose-invert max-w-none mt-3"> <div class="prose prose-surface dark:prose-invert max-w-none mt-3">
@@ -119,15 +119,10 @@ withSidebar: true
<section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite"> <section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2> <h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2"> <p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like <code>published</code>, <code>checkin</code>, <code>location</code>, <code>category</code>, and <code>checked-in-by</code>. This page reads local content created by your Micropub endpoint. Check-ins appear here when posts include fields like <code>checkin</code>, <code>location</code>, or coordinates.
</p> </p>
{% if whereCheckins.feedUrl %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2"> <p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
Current feed URL: <code>{{ whereCheckins.feedUrl }}</code> Scanned <code>{{ whereCheckins.scannedFiles or 0 }}</code> content files{% if whereCheckins.errors and whereCheckins.errors.length %} with {{ whereCheckins.errors.length }} parse warning(s){% endif %}.
</p>
{% endif %}
<p class="text-sm text-surface-600 dark:text-surface-400">
Configure <code>OWNYOURSWARM_FEED_URL</code> (and optionally <code>OWNYOURSWARM_FEED_TOKEN</code>) in your environment or deploy secrets.
</p> </p>
</section> </section>
{% endif %} {% endif %}
+6 -3
View File
@@ -3,10 +3,13 @@ import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: [
"./**/*.njk", "./*.njk",
"./**/*.md",
"./_includes/**/*.njk",
"./content/**/*.md", "./content/**/*.md",
"./docs/**/*.md",
"./.interface-design/**/*.md",
"./_includes/**/*.njk",
"./_includes/**/*.md",
"./js/**/*.js",
"./lib/**/*.js", "./lib/**/*.js",
], ],
darkMode: "class", darkMode: "class",
+173 -190
View File
@@ -1,33 +1,43 @@
/** /**
* Where/Checkin data * Where/Checkin data
* *
* Fetches h-entry checkins from an OwnYourSwarm-connected endpoint. * Reads local check-ins created by this site's Micropub endpoint.
* Expected payload: MF2 JSON with h-entry objects containing `checkin` and/or `location`. * A post is treated as a check-in when frontmatter includes checkin/location
* metadata, coordinates, or a checkin-like category.
*/ */
import EleventyFetch from "@11ty/eleventy-fetch"; import matter from "gray-matter";
import { readdirSync, readFileSync } from "node:fs";
import { extname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const FEED_URL = process.env.OWNYOURSWARM_FEED_URL || "https://ownyourswarm.p3k.io/"; const CONTENT_DIR = fileURLToPath(new URL("../content", import.meta.url));
const FEED_TOKEN = process.env.OWNYOURSWARM_FEED_TOKEN || "";
function first(value) { function first(value) {
if (Array.isArray(value)) return value[0]; if (Array.isArray(value)) return value[0];
return value; return value;
} }
function asArray(value) {
if (value === null || value === undefined || value === "") return [];
return Array.isArray(value) ? value : [value];
}
function asText(value) { function asText(value) {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
if (typeof value === "string") return value; if (typeof value === "string") return value;
if (typeof value === "number") return String(value); if (typeof value === "number") return String(value);
if (value instanceof Date) return value.toISOString();
if (typeof value === "object") { if (typeof value === "object") {
if (typeof value.value === "string") return value.value; if (typeof value.value === "string") return value.value;
if (typeof value.text === "string") return value.text; if (typeof value.text === "string") return value.text;
if (typeof value.url === "string") return value.url;
} }
return ""; return "";
} }
function asNumber(value) { function asNumber(value) {
const raw = first(value); const raw = first(asArray(value));
const num = Number(raw); const num = Number(raw);
return Number.isFinite(num) ? num : null; return Number.isFinite(num) ? num : null;
} }
@@ -36,120 +46,41 @@ function joinLocation(locality, region, country) {
return [locality, region, country].filter(Boolean).join(", "); return [locality, region, country].filter(Boolean).join(", ");
} }
function buildCandidateUrls(baseUrl) {
const raw = (baseUrl || "").trim();
if (!raw) return [];
const urls = [raw];
try {
const parsed = new URL(raw);
const pathWithoutSlash = parsed.pathname.replace(/\/$/, "");
const basePath = `${parsed.origin}${pathWithoutSlash}`;
const withFormat = new URL(parsed.toString());
withFormat.searchParams.set("format", "json");
urls.push(withFormat.toString());
const withOutput = new URL(parsed.toString());
withOutput.searchParams.set("output", "json");
urls.push(withOutput.toString());
if (pathWithoutSlash) {
urls.push(`${basePath}.json`);
urls.push(`${basePath}/checkins.json`);
urls.push(`${basePath}/feed.json`);
urls.push(`${basePath}/api/checkins`);
} else {
urls.push(`${parsed.origin}/checkins.json`);
urls.push(`${parsed.origin}/feed.json`);
urls.push(`${parsed.origin}/api/checkins`);
}
} catch {
// If URL parsing fails, we still try the raw URL above.
}
return [...new Set(urls)];
}
async function fetchJson(url) {
const headers = FEED_TOKEN ? { Authorization: `Bearer ${FEED_TOKEN}` } : {};
const fetchOptions = Object.keys(headers).length ? { headers } : undefined;
try {
return await EleventyFetch(url, {
duration: "15m",
type: "json",
fetchOptions,
});
} catch (jsonError) {
// Some endpoints serve JSON with an incorrect content type. Retry as text.
const text = await EleventyFetch(url, {
duration: "15m",
type: "text",
fetchOptions,
});
const trimmed = text.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
throw jsonError;
}
return JSON.parse(trimmed);
}
}
function looksLikeCheckinEntry(entry) {
if (!entry || typeof entry !== "object") return false;
const type = Array.isArray(entry.type) ? entry.type : [];
if (type.includes("h-entry")) {
const props = entry.properties || {};
return Boolean(props.checkin || props.location);
}
return false;
}
function extractEntries(payload) {
const queue = [];
if (Array.isArray(payload)) {
queue.push(...payload);
} else if (payload && typeof payload === "object") {
queue.push(payload);
}
const entries = [];
while (queue.length) {
const item = queue.shift();
if (!item || typeof item !== "object") continue;
if (looksLikeCheckinEntry(item)) {
entries.push(item);
}
if (Array.isArray(item.items)) queue.push(...item.items);
if (Array.isArray(item.children)) queue.push(...item.children);
if (item.data && Array.isArray(item.data.items)) queue.push(...item.data.items);
}
return entries;
}
function uniqueStrings(values) { function uniqueStrings(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
function toRelativePath(filePath) {
return relative(CONTENT_DIR, filePath).replace(/\\/g, "/");
}
function walkMarkdownFiles(dirPath) {
const files = [];
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...walkMarkdownFiles(fullPath));
continue;
}
if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") {
files.push(fullPath);
}
}
return files;
}
function parsePersonCard(card) { function parsePersonCard(card) {
if (!card || typeof card !== "object") return null; if (!card || typeof card !== "object") return null;
const props = card.properties || {}; const props = card.properties || {};
const urls = Array.isArray(props.url) const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean);
? props.url.map((url) => asText(url)).filter(Boolean) const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean);
: [];
const photos = Array.isArray(props.photo)
? props.photo.map((photo) => asText(photo)).filter(Boolean)
: [];
return { return {
name: asText(first(props.name)), name: asText(first(asArray(props.name))),
url: urls[0] || "", url: urls[0] || "",
urls, urls,
photo: photos[0] || "", photo: photos[0] || "",
@@ -168,6 +99,7 @@ function parseCategory(categoryValues) {
if (!value || typeof value !== "object") continue; if (!value || typeof value !== "object") continue;
const type = Array.isArray(value.type) ? value.type : []; const type = Array.isArray(value.type) ? value.type : [];
if (type.includes("h-card")) { if (type.includes("h-card")) {
const person = parsePersonCard(value); const person = parsePersonCard(value);
if (person && (person.name || person.url)) { if (person && (person.name || person.url)) {
@@ -176,70 +108,114 @@ function parseCategory(categoryValues) {
} }
} }
const normalizedTags = uniqueStrings(tags).filter(
(tag) => !["where", "slashpage"].includes(tag.toLowerCase())
);
return { return {
tags: uniqueStrings(tags), tags: normalizedTags,
people, people,
}; };
} }
function normalizeCheckin(entry) { function isCheckinFrontmatter(frontmatter, relativePath) {
const props = entry.properties || {}; if (relativePath === "pages/where.md") return false;
if (!props.checkin && !props.location) { const categories = asArray(frontmatter.category)
return null; .map((value) => asText(value).toLowerCase())
.filter(Boolean);
const hasCheckinField = frontmatter.checkin !== undefined || frontmatter["check-in"] !== undefined;
const hasLocationField = frontmatter.location !== undefined;
const hasCoordinates = frontmatter.latitude !== undefined || frontmatter.longitude !== undefined;
const hasCheckinCategory = categories.includes("where") || categories.includes("checkin") || categories.includes("swarm");
return hasCheckinField || hasLocationField || hasCoordinates || hasCheckinCategory;
} }
const checkinCard = first(props.checkin); function normalizeCheckin(frontmatter, relativePath) {
const locationCard = first(props.location); const checkinValue = first(asArray(frontmatter.checkin ?? frontmatter["check-in"]));
const locationValue = first(asArray(frontmatter.location));
const checkinProps = const checkinProps =
checkinCard && typeof checkinCard === "object" && checkinCard.properties checkinValue && typeof checkinValue === "object" && checkinValue.properties
? checkinCard.properties ? checkinValue.properties
: {}; : {};
const locationProps = const locationProps =
locationCard && typeof locationCard === "object" && locationCard.properties locationValue && typeof locationValue === "object" && locationValue.properties
? locationCard.properties ? locationValue.properties
: {}; : {};
const venueUrlsRaw = Array.isArray(checkinProps.url) const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean);
? checkinProps.url const venueUrlFromSimpleMode = typeof checkinValue === "string" ? checkinValue : "";
: checkinProps.url const venueUrls = venueUrlFromSimpleMode
? [checkinProps.url] ? [venueUrlFromSimpleMode, ...venueUrlsFromCard]
: []; : venueUrlsFromCard;
const venueUrls = venueUrlsRaw.map((url) => asText(url)).filter(Boolean);
const name = asText(first(checkinProps.name)) || "Unknown place"; const venueUrl = venueUrls[0] || "";
const venueUrl = venueUrls[0] || asText(checkinCard?.value);
const venueWebsiteUrl = venueUrls[1] || ""; const venueWebsiteUrl = venueUrls[1] || "";
const venueSocialUrl = venueUrls[2] || ""; const venueSocialUrl = venueUrls[2] || "";
const locality = asText(first(checkinProps.locality)) || asText(first(locationProps.locality)); const name =
const region = asText(first(checkinProps.region)) || asText(first(locationProps.region)); asText(first(asArray(checkinProps.name))) ||
asText(frontmatter.title) ||
"Unknown place";
const locality =
asText(first(asArray(checkinProps.locality))) ||
asText(first(asArray(locationProps.locality))) ||
asText(frontmatter.locality);
const region =
asText(first(asArray(checkinProps.region))) ||
asText(first(asArray(locationProps.region))) ||
asText(frontmatter.region);
const country = const country =
asText(first(checkinProps["country-name"])) || asText(first(asArray(checkinProps["country-name"]))) ||
asText(first(locationProps["country-name"])); asText(first(asArray(locationProps["country-name"]))) ||
asText(frontmatter["country-name"]);
const postalCode = const postalCode =
asText(first(checkinProps["postal-code"])) || asText(first(asArray(checkinProps["postal-code"]))) ||
asText(first(locationProps["postal-code"])); asText(first(asArray(locationProps["postal-code"]))) ||
asText(frontmatter["postal-code"]);
const latitude = const latitude =
asNumber(checkinProps.latitude) ?? asNumber(locationProps.latitude) ?? asNumber(props.latitude); asNumber(checkinProps.latitude) ??
asNumber(locationProps.latitude) ??
asNumber(frontmatter.latitude);
const longitude = const longitude =
asNumber(checkinProps.longitude) ?? asNumber(locationProps.longitude) ?? asNumber(props.longitude); asNumber(checkinProps.longitude) ??
asNumber(locationProps.longitude) ??
asNumber(frontmatter.longitude);
const published = asText(first(props.published)); const published =
const syndication = asText(first(props.syndication)); asText(first(asArray(frontmatter.published))) ||
const visibility = asText(first(props.visibility)).toLowerCase(); asText(frontmatter.date);
const categoryValues = Array.isArray(props.category) ? props.category : []; const syndicationUrls = asArray(frontmatter.syndication)
.map((url) => asText(url))
.filter(Boolean);
const syndication =
syndicationUrls.find((url) => url.includes("swarmapp.com")) ||
syndicationUrls[0] ||
"";
const visibility = asText(frontmatter.visibility).toLowerCase();
const categoryValues = asArray(frontmatter.category);
const category = parseCategory(categoryValues); const category = parseCategory(categoryValues);
const checkedInByCard = first(props["checked-in-by"]); const checkedInByValue = first(asArray(frontmatter["checked-in-by"] ?? frontmatter.checkedInBy));
const checkedInBy = parsePersonCard(checkedInByCard); const checkedInBy = parsePersonCard(checkedInByValue);
const photos = Array.isArray(props.photo) const photos = asArray(frontmatter.photo)
? props.photo.map((photo) => asText(photo)).filter(Boolean) .map((photo) => {
: []; if (typeof photo === "string") return photo;
if (photo && typeof photo === "object") {
return asText(photo.url || photo.value || photo.src || "");
}
return "";
})
.filter(Boolean);
const mapUrl = const mapUrl =
latitude !== null && longitude !== null latitude !== null && longitude !== null
@@ -253,10 +229,12 @@ function normalizeCheckin(entry) {
const locationText = joinLocation(locality, region, country); const locationText = joinLocation(locality, region, country);
const timestamp = published ? Date.parse(published) || 0 : 0; const timestamp = published ? Date.parse(published) || 0 : 0;
const id = syndication || `${published}-${name}-${coordinatesText}`; const permalink = asText(frontmatter.permalink);
const id = syndication || permalink || `${relativePath}-${published || "unknown"}`;
return { return {
id, id,
sourcePath: relativePath,
published, published,
timestamp, timestamp,
syndication, syndication,
@@ -282,16 +260,14 @@ function normalizeCheckin(entry) {
}; };
} }
function normalizeCheckins(entries) { function normalizeCheckins(items) {
const seen = new Set(); const seen = new Set();
const checkins = []; const checkins = [];
for (const entry of entries) { for (const item of items) {
const normalized = normalizeCheckin(entry); if (seen.has(item.id)) continue;
if (!normalized) continue; seen.add(item.id);
if (seen.has(normalized.id)) continue; checkins.push(item);
seen.add(normalized.id);
checkins.push(normalized);
} }
return checkins.sort((a, b) => b.timestamp - a.timestamp); return checkins.sort((a, b) => b.timestamp - a.timestamp);
@@ -299,28 +275,58 @@ function normalizeCheckins(entries) {
export default async function () { export default async function () {
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
const candidateUrls = buildCandidateUrls(FEED_URL);
const errors = []; const errors = [];
for (const url of candidateUrls) { let filePaths = [];
try {
console.log(`[whereCheckins] Fetching: ${url}`);
const payload = await fetchJson(url);
const entries = extractEntries(payload);
const checkins = normalizeCheckins(entries);
if (checkins.length > 0) { try {
filePaths = walkMarkdownFiles(CONTENT_DIR);
} catch (error) {
const message = `[whereCheckins] Unable to scan local content: ${error.message}`;
console.log(message);
return {
source: "local-endpoint",
available: false,
checkedAt,
scannedFiles: 0,
checkins: [],
errors: [message],
stats: {
total: 0,
withCoordinates: 0,
},
};
}
const items = [];
for (const filePath of filePaths) {
const relativePath = toRelativePath(filePath);
try {
const raw = readFileSync(filePath, "utf-8");
const frontmatter = matter(raw).data || {};
if (!isCheckinFrontmatter(frontmatter, relativePath)) continue;
const checkin = normalizeCheckin(frontmatter, relativePath);
items.push(checkin);
} catch (error) {
errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`);
}
}
const checkins = normalizeCheckins(items);
const withCoordinates = checkins.filter( const withCoordinates = checkins.filter(
(item) => item.latitude !== null && item.longitude !== null (item) => item.latitude !== null && item.longitude !== null
).length; ).length;
return { return {
feedUrl: url, source: "local-endpoint",
checkins, available: checkins.length > 0,
source: "ownyourswarm",
available: true,
checkedAt, checkedAt,
triedUrls: candidateUrls, scannedFiles: filePaths.length,
checkins,
errors, errors,
stats: { stats: {
total: checkins.length, total: checkins.length,
@@ -328,26 +334,3 @@ export default async function () {
}, },
}; };
} }
errors.push(`No checkin h-entry objects found at ${url}`);
} catch (error) {
const message = `[whereCheckins] Unable to use ${url}: ${error.message}`;
console.log(message);
errors.push(message);
}
}
return {
feedUrl: FEED_URL,
checkins: [],
source: "unavailable",
available: false,
checkedAt,
triedUrls: candidateUrls,
errors,
stats: {
total: 0,
withCoordinates: 0,
},
};
}
+3 -8
View File
@@ -8,7 +8,7 @@ withSidebar: true
<header class="mb-6 sm:mb-8"> <header class="mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ title or "Where" }}</h1> <h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ title or "Where" }}</h1>
<p class="text-surface-600 dark:text-surface-400"> <p class="text-surface-600 dark:text-surface-400">
Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. Recent check-ins captured by this site via Micropub.
</p> </p>
{% if content %} {% if content %}
<div class="prose prose-surface dark:prose-invert max-w-none mt-3"> <div class="prose prose-surface dark:prose-invert max-w-none mt-3">
@@ -119,15 +119,10 @@ withSidebar: true
<section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite"> <section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2> <h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2"> <p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like <code>published</code>, <code>checkin</code>, <code>location</code>, <code>category</code>, and <code>checked-in-by</code>. This page reads local content created by your Micropub endpoint. Check-ins appear here when posts include fields like <code>checkin</code>, <code>location</code>, or coordinates.
</p> </p>
{% if whereCheckins.feedUrl %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2"> <p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
Current feed URL: <code>{{ whereCheckins.feedUrl }}</code> Scanned <code>{{ whereCheckins.scannedFiles or 0 }}</code> content files{% if whereCheckins.errors and whereCheckins.errors.length %} with {{ whereCheckins.errors.length }} parse warning(s){% endif %}.
</p>
{% endif %}
<p class="text-sm text-surface-600 dark:text-surface-400">
Configure <code>OWNYOURSWARM_FEED_URL</code> (and optionally <code>OWNYOURSWARM_FEED_TOKEN</code>) in your environment or deploy secrets.
</p> </p>
</section> </section>
{% endif %} {% endif %}
+6 -3
View File
@@ -3,10 +3,13 @@ import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: [
"./**/*.njk", "./*.njk",
"./**/*.md",
"./_includes/**/*.njk",
"./content/**/*.md", "./content/**/*.md",
"./docs/**/*.md",
"./.interface-design/**/*.md",
"./_includes/**/*.njk",
"./_includes/**/*.md",
"./js/**/*.js",
"./lib/**/*.js", "./lib/**/*.js",
], ],
darkMode: "class", darkMode: "class",
+3 -8
View File
@@ -10,7 +10,7 @@ withSidebar: true
<header class="mb-6 sm:mb-8"> <header class="mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Where</h1> <h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Where</h1>
<p class="text-surface-600 dark:text-surface-400"> <p class="text-surface-600 dark:text-surface-400">
Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. Recent check-ins captured by this site via Micropub.
</p> </p>
{% if whereCheckins.available %} {% if whereCheckins.available %}
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2"> <p class="text-xs text-surface-600 dark:text-surface-400 mt-2">
@@ -116,15 +116,10 @@ withSidebar: true
<section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite"> <section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2> <h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2"> <p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like <code>published</code>, <code>checkin</code>, <code>location</code>, <code>category</code>, and <code>checked-in-by</code>. This page reads local content created by your Micropub endpoint. Check-ins appear here when posts include fields like <code>checkin</code>, <code>location</code>, or coordinates.
</p> </p>
{% if whereCheckins.feedUrl %}
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2"> <p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
Current feed URL: <code>{{ whereCheckins.feedUrl }}</code> Scanned <code>{{ whereCheckins.scannedFiles or 0 }}</code> content files{% if whereCheckins.errors and whereCheckins.errors.length %} with {{ whereCheckins.errors.length }} parse warning(s){% endif %}.
</p>
{% endif %}
<p class="text-sm text-surface-600 dark:text-surface-400">
Configure <code>OWNYOURSWARM_FEED_URL</code> (and optionally <code>OWNYOURSWARM_FEED_TOKEN</code>) in your environment or deploy secrets.
</p> </p>
</section> </section>
{% endif %} {% endif %}