From c7a30102b7ac1d0a273fdd0f60e9e0ade6417619 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:47:10 +0100 Subject: [PATCH] refactor(where): read local checkins from content --- _data/whereCheckins.js | 365 ++++++++++++++---------------- _includes/layouts/where.njk | 11 +- tailwind.config.js | 9 +- theme/_data/whereCheckins.js | 365 ++++++++++++++---------------- theme/_includes/layouts/where.njk | 11 +- theme/tailwind.config.js | 9 +- theme/where.njk | 11 +- 7 files changed, 369 insertions(+), 412 deletions(-) diff --git a/_data/whereCheckins.js b/_data/whereCheckins.js index dcf17a1..f9b9e78 100644 --- a/_data/whereCheckins.js +++ b/_data/whereCheckins.js @@ -1,33 +1,43 @@ /** * Where/Checkin data * - * Fetches h-entry checkins from an OwnYourSwarm-connected endpoint. - * Expected payload: MF2 JSON with h-entry objects containing `checkin` and/or `location`. + * Reads local check-ins created by this site's Micropub endpoint. + * 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 FEED_TOKEN = process.env.OWNYOURSWARM_FEED_TOKEN || ""; +const CONTENT_DIR = fileURLToPath(new URL("../content", import.meta.url)); function first(value) { if (Array.isArray(value)) return value[0]; return value; } +function asArray(value) { + if (value === null || value === undefined || value === "") return []; + return Array.isArray(value) ? value : [value]; +} + function asText(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number") return String(value); + if (value instanceof Date) return value.toISOString(); if (typeof value === "object") { if (typeof value.value === "string") return value.value; if (typeof value.text === "string") return value.text; + if (typeof value.url === "string") return value.url; } return ""; } function asNumber(value) { - const raw = first(value); + const raw = first(asArray(value)); const num = Number(raw); return Number.isFinite(num) ? num : null; } @@ -36,120 +46,41 @@ function joinLocation(locality, region, country) { 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) { 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) { if (!card || typeof card !== "object") return null; const props = card.properties || {}; - const urls = Array.isArray(props.url) - ? props.url.map((url) => asText(url)).filter(Boolean) - : []; - const photos = Array.isArray(props.photo) - ? props.photo.map((photo) => asText(photo)).filter(Boolean) - : []; + const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean); + const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean); return { - name: asText(first(props.name)), + name: asText(first(asArray(props.name))), url: urls[0] || "", urls, photo: photos[0] || "", @@ -168,6 +99,7 @@ function parseCategory(categoryValues) { if (!value || typeof value !== "object") continue; const type = Array.isArray(value.type) ? value.type : []; + if (type.includes("h-card")) { const person = parsePersonCard(value); 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 { - tags: uniqueStrings(tags), + tags: normalizedTags, people, }; } -function normalizeCheckin(entry) { - const props = entry.properties || {}; +function isCheckinFrontmatter(frontmatter, relativePath) { + if (relativePath === "pages/where.md") return false; - if (!props.checkin && !props.location) { - return null; - } + const categories = asArray(frontmatter.category) + .map((value) => asText(value).toLowerCase()) + .filter(Boolean); - const checkinCard = first(props.checkin); - const locationCard = first(props.location); + 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; +} + +function normalizeCheckin(frontmatter, relativePath) { + const checkinValue = first(asArray(frontmatter.checkin ?? frontmatter["check-in"])); + const locationValue = first(asArray(frontmatter.location)); const checkinProps = - checkinCard && typeof checkinCard === "object" && checkinCard.properties - ? checkinCard.properties + checkinValue && typeof checkinValue === "object" && checkinValue.properties + ? checkinValue.properties : {}; const locationProps = - locationCard && typeof locationCard === "object" && locationCard.properties - ? locationCard.properties + locationValue && typeof locationValue === "object" && locationValue.properties + ? locationValue.properties : {}; - const venueUrlsRaw = Array.isArray(checkinProps.url) - ? checkinProps.url - : checkinProps.url - ? [checkinProps.url] - : []; - const venueUrls = venueUrlsRaw.map((url) => asText(url)).filter(Boolean); + const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean); + const venueUrlFromSimpleMode = typeof checkinValue === "string" ? checkinValue : ""; + const venueUrls = venueUrlFromSimpleMode + ? [venueUrlFromSimpleMode, ...venueUrlsFromCard] + : venueUrlsFromCard; - const name = asText(first(checkinProps.name)) || "Unknown place"; - const venueUrl = venueUrls[0] || asText(checkinCard?.value); + const venueUrl = venueUrls[0] || ""; const venueWebsiteUrl = venueUrls[1] || ""; const venueSocialUrl = venueUrls[2] || ""; - const locality = asText(first(checkinProps.locality)) || asText(first(locationProps.locality)); - const region = asText(first(checkinProps.region)) || asText(first(locationProps.region)); + const name = + 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 = - asText(first(checkinProps["country-name"])) || - asText(first(locationProps["country-name"])); + asText(first(asArray(checkinProps["country-name"]))) || + asText(first(asArray(locationProps["country-name"]))) || + asText(frontmatter["country-name"]); const postalCode = - asText(first(checkinProps["postal-code"])) || - asText(first(locationProps["postal-code"])); + asText(first(asArray(checkinProps["postal-code"]))) || + asText(first(asArray(locationProps["postal-code"]))) || + asText(frontmatter["postal-code"]); const latitude = - asNumber(checkinProps.latitude) ?? asNumber(locationProps.latitude) ?? asNumber(props.latitude); + asNumber(checkinProps.latitude) ?? + asNumber(locationProps.latitude) ?? + asNumber(frontmatter.latitude); 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 syndication = asText(first(props.syndication)); - const visibility = asText(first(props.visibility)).toLowerCase(); + const published = + asText(first(asArray(frontmatter.published))) || + 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 checkedInByCard = first(props["checked-in-by"]); - const checkedInBy = parsePersonCard(checkedInByCard); + const checkedInByValue = first(asArray(frontmatter["checked-in-by"] ?? frontmatter.checkedInBy)); + const checkedInBy = parsePersonCard(checkedInByValue); - const photos = Array.isArray(props.photo) - ? props.photo.map((photo) => asText(photo)).filter(Boolean) - : []; + const photos = asArray(frontmatter.photo) + .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 = latitude !== null && longitude !== null @@ -253,10 +229,12 @@ function normalizeCheckin(entry) { const locationText = joinLocation(locality, region, country); 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 { id, + sourcePath: relativePath, published, timestamp, syndication, @@ -282,16 +260,14 @@ function normalizeCheckin(entry) { }; } -function normalizeCheckins(entries) { +function normalizeCheckins(items) { const seen = new Set(); const checkins = []; - for (const entry of entries) { - const normalized = normalizeCheckin(entry); - if (!normalized) continue; - if (seen.has(normalized.id)) continue; - seen.add(normalized.id); - checkins.push(normalized); + for (const item of items) { + if (seen.has(item.id)) continue; + seen.add(item.id); + checkins.push(item); } return checkins.sort((a, b) => b.timestamp - a.timestamp); @@ -299,55 +275,62 @@ function normalizeCheckins(entries) { export default async function () { const checkedAt = new Date().toISOString(); - const candidateUrls = buildCandidateUrls(FEED_URL); const errors = []; - for (const url of candidateUrls) { + let filePaths = []; + + 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 { - console.log(`[whereCheckins] Fetching: ${url}`); - const payload = await fetchJson(url); - const entries = extractEntries(payload); - const checkins = normalizeCheckins(entries); + const raw = readFileSync(filePath, "utf-8"); + const frontmatter = matter(raw).data || {}; - if (checkins.length > 0) { - const withCoordinates = checkins.filter( - (item) => item.latitude !== null && item.longitude !== null - ).length; + if (!isCheckinFrontmatter(frontmatter, relativePath)) continue; - return { - feedUrl: url, - checkins, - source: "ownyourswarm", - available: true, - checkedAt, - triedUrls: candidateUrls, - errors, - stats: { - total: checkins.length, - withCoordinates, - }, - }; - } - - errors.push(`No checkin h-entry objects found at ${url}`); + const checkin = normalizeCheckin(frontmatter, relativePath); + items.push(checkin); } catch (error) { - const message = `[whereCheckins] Unable to use ${url}: ${error.message}`; - console.log(message); - errors.push(message); + errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`); } } + const checkins = normalizeCheckins(items); + const withCoordinates = checkins.filter( + (item) => item.latitude !== null && item.longitude !== null + ).length; + return { - feedUrl: FEED_URL, - checkins: [], - source: "unavailable", - available: false, + source: "local-endpoint", + available: checkins.length > 0, checkedAt, - triedUrls: candidateUrls, + scannedFiles: filePaths.length, + checkins, errors, stats: { - total: 0, - withCoordinates: 0, + total: checkins.length, + withCoordinates, }, }; } diff --git a/_includes/layouts/where.njk b/_includes/layouts/where.njk index bb67132..1067005 100644 --- a/_includes/layouts/where.njk +++ b/_includes/layouts/where.njk @@ -8,7 +8,7 @@ withSidebar: true

{{ title or "Where" }}

- Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. + Recent check-ins captured by this site via Micropub.

{% if content %}
@@ -119,15 +119,10 @@ withSidebar: true

No check-ins available yet

- This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like published, checkin, location, category, and checked-in-by. + This page reads local content created by your Micropub endpoint. Check-ins appear here when posts include fields like checkin, location, or coordinates.

- {% if whereCheckins.feedUrl %}

- Current feed URL: {{ whereCheckins.feedUrl }} -

- {% endif %} -

- Configure OWNYOURSWARM_FEED_URL (and optionally OWNYOURSWARM_FEED_TOKEN) in your environment or deploy secrets. + Scanned {{ whereCheckins.scannedFiles or 0 }} content files{% if whereCheckins.errors and whereCheckins.errors.length %} with {{ whereCheckins.errors.length }} parse warning(s){% endif %}.

{% endif %} diff --git a/tailwind.config.js b/tailwind.config.js index 52e6bbb..0b36b2e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,10 +3,13 @@ import typography from "@tailwindcss/typography"; /** @type {import('tailwindcss').Config} */ export default { content: [ - "./**/*.njk", - "./**/*.md", - "./_includes/**/*.njk", + "./*.njk", "./content/**/*.md", + "./docs/**/*.md", + "./.interface-design/**/*.md", + "./_includes/**/*.njk", + "./_includes/**/*.md", + "./js/**/*.js", "./lib/**/*.js", ], darkMode: "class", diff --git a/theme/_data/whereCheckins.js b/theme/_data/whereCheckins.js index dcf17a1..f9b9e78 100644 --- a/theme/_data/whereCheckins.js +++ b/theme/_data/whereCheckins.js @@ -1,33 +1,43 @@ /** * Where/Checkin data * - * Fetches h-entry checkins from an OwnYourSwarm-connected endpoint. - * Expected payload: MF2 JSON with h-entry objects containing `checkin` and/or `location`. + * Reads local check-ins created by this site's Micropub endpoint. + * 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 FEED_TOKEN = process.env.OWNYOURSWARM_FEED_TOKEN || ""; +const CONTENT_DIR = fileURLToPath(new URL("../content", import.meta.url)); function first(value) { if (Array.isArray(value)) return value[0]; return value; } +function asArray(value) { + if (value === null || value === undefined || value === "") return []; + return Array.isArray(value) ? value : [value]; +} + function asText(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number") return String(value); + if (value instanceof Date) return value.toISOString(); if (typeof value === "object") { if (typeof value.value === "string") return value.value; if (typeof value.text === "string") return value.text; + if (typeof value.url === "string") return value.url; } return ""; } function asNumber(value) { - const raw = first(value); + const raw = first(asArray(value)); const num = Number(raw); return Number.isFinite(num) ? num : null; } @@ -36,120 +46,41 @@ function joinLocation(locality, region, country) { 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) { 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) { if (!card || typeof card !== "object") return null; const props = card.properties || {}; - const urls = Array.isArray(props.url) - ? props.url.map((url) => asText(url)).filter(Boolean) - : []; - const photos = Array.isArray(props.photo) - ? props.photo.map((photo) => asText(photo)).filter(Boolean) - : []; + const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean); + const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean); return { - name: asText(first(props.name)), + name: asText(first(asArray(props.name))), url: urls[0] || "", urls, photo: photos[0] || "", @@ -168,6 +99,7 @@ function parseCategory(categoryValues) { if (!value || typeof value !== "object") continue; const type = Array.isArray(value.type) ? value.type : []; + if (type.includes("h-card")) { const person = parsePersonCard(value); 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 { - tags: uniqueStrings(tags), + tags: normalizedTags, people, }; } -function normalizeCheckin(entry) { - const props = entry.properties || {}; +function isCheckinFrontmatter(frontmatter, relativePath) { + if (relativePath === "pages/where.md") return false; - if (!props.checkin && !props.location) { - return null; - } + const categories = asArray(frontmatter.category) + .map((value) => asText(value).toLowerCase()) + .filter(Boolean); - const checkinCard = first(props.checkin); - const locationCard = first(props.location); + 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; +} + +function normalizeCheckin(frontmatter, relativePath) { + const checkinValue = first(asArray(frontmatter.checkin ?? frontmatter["check-in"])); + const locationValue = first(asArray(frontmatter.location)); const checkinProps = - checkinCard && typeof checkinCard === "object" && checkinCard.properties - ? checkinCard.properties + checkinValue && typeof checkinValue === "object" && checkinValue.properties + ? checkinValue.properties : {}; const locationProps = - locationCard && typeof locationCard === "object" && locationCard.properties - ? locationCard.properties + locationValue && typeof locationValue === "object" && locationValue.properties + ? locationValue.properties : {}; - const venueUrlsRaw = Array.isArray(checkinProps.url) - ? checkinProps.url - : checkinProps.url - ? [checkinProps.url] - : []; - const venueUrls = venueUrlsRaw.map((url) => asText(url)).filter(Boolean); + const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean); + const venueUrlFromSimpleMode = typeof checkinValue === "string" ? checkinValue : ""; + const venueUrls = venueUrlFromSimpleMode + ? [venueUrlFromSimpleMode, ...venueUrlsFromCard] + : venueUrlsFromCard; - const name = asText(first(checkinProps.name)) || "Unknown place"; - const venueUrl = venueUrls[0] || asText(checkinCard?.value); + const venueUrl = venueUrls[0] || ""; const venueWebsiteUrl = venueUrls[1] || ""; const venueSocialUrl = venueUrls[2] || ""; - const locality = asText(first(checkinProps.locality)) || asText(first(locationProps.locality)); - const region = asText(first(checkinProps.region)) || asText(first(locationProps.region)); + const name = + 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 = - asText(first(checkinProps["country-name"])) || - asText(first(locationProps["country-name"])); + asText(first(asArray(checkinProps["country-name"]))) || + asText(first(asArray(locationProps["country-name"]))) || + asText(frontmatter["country-name"]); const postalCode = - asText(first(checkinProps["postal-code"])) || - asText(first(locationProps["postal-code"])); + asText(first(asArray(checkinProps["postal-code"]))) || + asText(first(asArray(locationProps["postal-code"]))) || + asText(frontmatter["postal-code"]); const latitude = - asNumber(checkinProps.latitude) ?? asNumber(locationProps.latitude) ?? asNumber(props.latitude); + asNumber(checkinProps.latitude) ?? + asNumber(locationProps.latitude) ?? + asNumber(frontmatter.latitude); 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 syndication = asText(first(props.syndication)); - const visibility = asText(first(props.visibility)).toLowerCase(); + const published = + asText(first(asArray(frontmatter.published))) || + 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 checkedInByCard = first(props["checked-in-by"]); - const checkedInBy = parsePersonCard(checkedInByCard); + const checkedInByValue = first(asArray(frontmatter["checked-in-by"] ?? frontmatter.checkedInBy)); + const checkedInBy = parsePersonCard(checkedInByValue); - const photos = Array.isArray(props.photo) - ? props.photo.map((photo) => asText(photo)).filter(Boolean) - : []; + const photos = asArray(frontmatter.photo) + .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 = latitude !== null && longitude !== null @@ -253,10 +229,12 @@ function normalizeCheckin(entry) { const locationText = joinLocation(locality, region, country); 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 { id, + sourcePath: relativePath, published, timestamp, syndication, @@ -282,16 +260,14 @@ function normalizeCheckin(entry) { }; } -function normalizeCheckins(entries) { +function normalizeCheckins(items) { const seen = new Set(); const checkins = []; - for (const entry of entries) { - const normalized = normalizeCheckin(entry); - if (!normalized) continue; - if (seen.has(normalized.id)) continue; - seen.add(normalized.id); - checkins.push(normalized); + for (const item of items) { + if (seen.has(item.id)) continue; + seen.add(item.id); + checkins.push(item); } return checkins.sort((a, b) => b.timestamp - a.timestamp); @@ -299,55 +275,62 @@ function normalizeCheckins(entries) { export default async function () { const checkedAt = new Date().toISOString(); - const candidateUrls = buildCandidateUrls(FEED_URL); const errors = []; - for (const url of candidateUrls) { + let filePaths = []; + + 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 { - console.log(`[whereCheckins] Fetching: ${url}`); - const payload = await fetchJson(url); - const entries = extractEntries(payload); - const checkins = normalizeCheckins(entries); + const raw = readFileSync(filePath, "utf-8"); + const frontmatter = matter(raw).data || {}; - if (checkins.length > 0) { - const withCoordinates = checkins.filter( - (item) => item.latitude !== null && item.longitude !== null - ).length; + if (!isCheckinFrontmatter(frontmatter, relativePath)) continue; - return { - feedUrl: url, - checkins, - source: "ownyourswarm", - available: true, - checkedAt, - triedUrls: candidateUrls, - errors, - stats: { - total: checkins.length, - withCoordinates, - }, - }; - } - - errors.push(`No checkin h-entry objects found at ${url}`); + const checkin = normalizeCheckin(frontmatter, relativePath); + items.push(checkin); } catch (error) { - const message = `[whereCheckins] Unable to use ${url}: ${error.message}`; - console.log(message); - errors.push(message); + errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`); } } + const checkins = normalizeCheckins(items); + const withCoordinates = checkins.filter( + (item) => item.latitude !== null && item.longitude !== null + ).length; + return { - feedUrl: FEED_URL, - checkins: [], - source: "unavailable", - available: false, + source: "local-endpoint", + available: checkins.length > 0, checkedAt, - triedUrls: candidateUrls, + scannedFiles: filePaths.length, + checkins, errors, stats: { - total: 0, - withCoordinates: 0, + total: checkins.length, + withCoordinates, }, }; } diff --git a/theme/_includes/layouts/where.njk b/theme/_includes/layouts/where.njk index 4a49680..1d095d7 100644 --- a/theme/_includes/layouts/where.njk +++ b/theme/_includes/layouts/where.njk @@ -8,7 +8,7 @@ withSidebar: true

{{ title or "Where" }}

- Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. + Recent check-ins captured by this site via Micropub.

{% if content %}
@@ -119,15 +119,10 @@ withSidebar: true

No check-ins available yet

- This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like published, checkin, location, category, and checked-in-by. + This page reads local content created by your Micropub endpoint. Check-ins appear here when posts include fields like checkin, location, or coordinates.

- {% if whereCheckins.feedUrl %}

- Current feed URL: {{ whereCheckins.feedUrl }} -

- {% endif %} -

- Configure OWNYOURSWARM_FEED_URL (and optionally OWNYOURSWARM_FEED_TOKEN) in your environment or deploy secrets. + Scanned {{ whereCheckins.scannedFiles or 0 }} content files{% if whereCheckins.errors and whereCheckins.errors.length %} with {{ whereCheckins.errors.length }} parse warning(s){% endif %}.

{% endif %} diff --git a/theme/tailwind.config.js b/theme/tailwind.config.js index 3d3477f..8b5b1b0 100644 --- a/theme/tailwind.config.js +++ b/theme/tailwind.config.js @@ -3,10 +3,13 @@ import typography from "@tailwindcss/typography"; /** @type {import('tailwindcss').Config} */ export default { content: [ - "./**/*.njk", - "./**/*.md", - "./_includes/**/*.njk", + "./*.njk", "./content/**/*.md", + "./docs/**/*.md", + "./.interface-design/**/*.md", + "./_includes/**/*.njk", + "./_includes/**/*.md", + "./js/**/*.js", "./lib/**/*.js", ], darkMode: "class", diff --git a/theme/where.njk b/theme/where.njk index 1bf9c6d..0105c9f 100644 --- a/theme/where.njk +++ b/theme/where.njk @@ -10,7 +10,7 @@ withSidebar: true

Where

- Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. + Recent check-ins captured by this site via Micropub.

{% if whereCheckins.available %}

@@ -116,15 +116,10 @@ withSidebar: true

No check-ins available yet

- This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like published, checkin, location, category, and checked-in-by. + This page reads local content created by your Micropub endpoint. Check-ins appear here when posts include fields like checkin, location, or coordinates.

- {% if whereCheckins.feedUrl %}

- Current feed URL: {{ whereCheckins.feedUrl }} -

- {% endif %} -

- Configure OWNYOURSWARM_FEED_URL (and optionally OWNYOURSWARM_FEED_TOKEN) in your environment or deploy secrets. + Scanned {{ whereCheckins.scannedFiles or 0 }} content files{% if whereCheckins.errors and whereCheckins.errors.length %} with {{ whereCheckins.errors.length }} parse warning(s){% endif %}.

{% endif %}