Commit 249b1b9c by PLN (Algolia)

refactor(content): unify the content loaders + add a presence seam

Phase 1 Step 5. Fold lib/utils.js (flat Markdown collections) and
lib/livesData.js (year-foldered ParVagues gigs) into one module,
lib/content/index.ts — two collection shapes in one entry, not one
forced shape. Thin section wrappers (posts/hydras/poems/talks) now
re-export from @/lib/content; the two ParVagues pages import the lives
API from there too. Old lib/utils.js and lib/livesData.js deleted.

- strip the per-file console.log noise that was in lib/utils.js;
- getAllContentIds now returns plain string[]; the three dynamic pages
  drop the vestigial Pages-Router `.map(({params}) => params)` adapter
  for `.map((id) => ({ id }))`;
- add lib/content/parvagues-presence.ts: a single thin seam holding the
  already-in-repo platform links, so a future tooling-emitted
  presence.json swaps in without touching components. No schema, no
  invented links, components not yet rewired (deliberate).

Verified: prod build green (14 routes, all dynamic params intact);
fresh-dev smoke 14/14.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 9a2498a9
...@@ -5,7 +5,7 @@ import { getAllHydraIds, getHydraData } from "@/lib/hydras"; ...@@ -5,7 +5,7 @@ import { getAllHydraIds, getHydraData } from "@/lib/hydras";
import HydraClient from "./HydraClient"; import HydraClient from "./HydraClient";
export function generateStaticParams() { export function generateStaticParams() {
return getAllHydraIds().map(({ params }) => params); return getAllHydraIds().map((id) => ({ id }));
} }
export async function generateMetadata({ params }) { export async function generateMetadata({ params }) {
......
import { getAllLives, getLiveData, getLivesImages, getLiveTracks } from '@/lib/livesData'; import { getAllLives, getLiveData, getLivesImages, getLiveTracks } from '@/lib/content';
import LiveEvent from '@/components/parvagues/LiveEvent'; import LiveEvent from '@/components/parvagues/LiveEvent';
export const revalidate = 60; export const revalidate = 60;
......
import { getAllLives } from '@/lib/livesData'; import { getAllLives } from '@/lib/content';
import Hero from '@/components/parvagues/Hero'; import Hero from '@/components/parvagues/Hero';
import BioSection from '@/components/parvagues/BioSection'; import BioSection from '@/components/parvagues/BioSection';
import AsSeenAt from '@/components/parvagues/AsSeenAt'; import AsSeenAt from '@/components/parvagues/AsSeenAt';
......
...@@ -5,7 +5,7 @@ import { getPoemData, getAllPoemIds } from "@/lib/poems"; ...@@ -5,7 +5,7 @@ import { getPoemData, getAllPoemIds } from "@/lib/poems";
import Link from "next/link"; import Link from "next/link";
export function generateStaticParams() { export function generateStaticParams() {
return getAllPoemIds().map(({ params }) => params); return getAllPoemIds().map((id) => ({ id }));
} }
export async function generateMetadata({ params }) { export async function generateMetadata({ params }) {
......
...@@ -4,7 +4,7 @@ import utilStyles from "@/styles/utils.module.css"; ...@@ -4,7 +4,7 @@ import utilStyles from "@/styles/utils.module.css";
import { getAllPostIds, getPostData } from "@/lib/posts"; import { getAllPostIds, getPostData } from "@/lib/posts";
export function generateStaticParams() { export function generateStaticParams() {
return getAllPostIds().map(({ params }) => params); return getAllPostIds().map((id) => ({ id }));
} }
export async function generateMetadata({ params }) { export async function generateMetadata({ params }) {
......
import path from 'path';
import fs from 'fs';
import matter from 'gray-matter';
import { remark } from 'remark';
import remarkHtml from 'remark-html';
/**
* Unified content loader.
*
* Two collection shapes live here, on purpose — not one forced shape:
* - flat Markdown collections (posts, poems, talks, hydras): one .md per item
* under content/<section>/, frontmatter + rendered HTML body;
* - ParVagues "lives": a richer, year-foldered gig collection with optional
* tracks.json and gig-photo folders.
*
* Thin section wrappers (lib/posts.js, lib/hydras.js, …) bind a section name to
* the flat API; the ParVagues pages call the lives API directly.
*/
const CONTENT_ROOT = path.join(process.cwd(), 'content');
/* ------------------------------------------------------------------ *
* Flat Markdown collections — content/<section>/<id>.md
* ------------------------------------------------------------------ */
function sectionDir(section: string) {
return path.join(CONTENT_ROOT, section);
}
function isMarkdownFile(fileName: string) {
return fileName.endsWith('.md') && !fileName.endsWith('.mdx.md');
}
function idFromFileName(fileName: string) {
return fileName.replace(/\.md$/, '');
}
async function renderMarkdown(md: string) {
const processed = await remark().use(remarkHtml).process(md);
return processed.toString();
}
export function getContentDirectory(section: string) {
return sectionDir(section);
}
/** Frontmatter-only listing (used by index/listing pages). */
export function getAllContentData(section: string, sorted = false) {
const dir = sectionDir(section);
const entries = fs
.readdirSync(dir)
.filter(isMarkdownFile)
.map((fileName) => {
const { data } = matter(fs.readFileSync(path.join(dir, fileName), 'utf8'));
return { id: idFromFileName(fileName), ...data };
});
return sorted ? entries.sort((a, b) => (a.date < b.date ? 1 : -1)) : entries;
}
/** Plain id list. App Router callers map it to params: ids.map((id) => ({ id })). */
export function getAllContentIds(section: string): string[] {
return fs.readdirSync(sectionDir(section)).filter(isMarkdownFile).map(idFromFileName);
}
/** Single item with rendered HTML body (and HTML-rendered description, if any). */
export async function getContentData(section: string, id: string) {
const fileName = id.endsWith('.md') ? id : `${id}.md`;
const fullPath = path.join(sectionDir(section), fileName);
if (!fs.existsSync(fullPath)) {
throw new Error(`No content found for ${section}/${id}`);
}
const { data, content } = matter(fs.readFileSync(fullPath, 'utf8'));
const contentHtml = await renderMarkdown(content);
if (typeof data.description === 'string') {
data.description = await renderMarkdown(data.description);
}
return { id: idFromFileName(fileName), contentHtml, ...data };
}
/* ------------------------------------------------------------------ *
* ParVagues lives — content/lives/<year>/<slug>.md
* + optional content/lives/<year>/<slug>/tracks.json
* + gig photos under public/images/parvagues/lives/<year>/<slug>/
* ------------------------------------------------------------------ */
const LIVES_ROOT = path.join(CONTENT_ROOT, 'lives');
function liveYears(): string[] {
return fs
.readdirSync(LIVES_ROOT)
.filter((item) => fs.statSync(path.join(LIVES_ROOT, item)).isDirectory());
}
/** All gigs across every year folder, newest-first. */
export function getAllLives() {
const lives: any[] = [];
for (const year of liveYears()) {
const yearPath = path.join(LIVES_ROOT, year);
for (const fileName of fs.readdirSync(yearPath)) {
if (!fileName.endsWith('.md')) continue;
const slug = fileName.replace(/\.md$/, '');
const { data } = matter(fs.readFileSync(path.join(yearPath, fileName), 'utf8'));
lives.push({ slug, year, ...data });
}
}
return lives.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export async function getLiveData(slug: string) {
for (const year of liveYears()) {
const filePath = path.join(LIVES_ROOT, year, `${slug}.md`);
if (fs.existsSync(filePath)) {
const { data, content } = matter(fs.readFileSync(filePath, 'utf8'));
return { slug, year, frontmatter: data, content };
}
}
throw new Error(`Live with slug "${slug}" not found`);
}
export function getLiveTracks(slug: string) {
for (const year of liveYears()) {
const tracksPath = path.join(LIVES_ROOT, year, slug, 'tracks.json');
if (fs.existsSync(tracksPath)) {
return JSON.parse(fs.readFileSync(tracksPath, 'utf8'));
}
}
return null;
}
export function getLivesImages(slug: string): string[] {
for (const year of liveYears()) {
const imagesPath = path.join(process.cwd(), 'public/images/parvagues/lives', year, slug);
if (fs.existsSync(imagesPath)) {
return fs
.readdirSync(imagesPath)
.filter((file) => /\.(jpg|jpeg|png|gif|webp)$/i.test(file))
.map((file) => `/images/parvagues/lives/${year}/${slug}/${file}`);
}
}
return [];
}
/**
* ParVagues presence — the single seam for the artist's platform links.
*
* These are hardcoded here today, lifted into one place. Later a separate
* tooling project will emit a `presence.json` SSOT; when it lands, swap the
* body of `getParvaguesPresence()` to read that file and nothing downstream
* should need to change.
*
* Deliberately minimal: this is NOT a schema and NOT a place to invent links.
* It holds only links that already exist in the repo. Components are not yet
* wired to it — that rewire happens when presence.json actually arrives.
*/
export type PresenceLink = {
/** stable platform key, e.g. 'instagram' | 'youtube' */
platform: string;
label: string;
href: string;
};
const PRESENCE: PresenceLink[] = [
{ platform: 'instagram', label: 'Instagram', href: 'https://instagram.com/parvagues.mp3' },
{ platform: 'gitlab', label: 'GitLab', href: 'http://nech.pl/Tidal' },
{ platform: 'youtube', label: 'YouTube', href: 'https://youtube.com/@parvagues' },
{ platform: 'mastodon', label: 'Mastodon', href: 'https://chaos.social/@PixelNoir' },
];
export function getParvaguesPresence(): PresenceLink[] {
return PRESENCE;
}
import {getAllContentData, getAllContentIds, getContentData} from './utils' import {getAllContentData, getAllContentIds, getContentData} from '@/lib/content'
export function getHydrasData() { export function getHydrasData() {
return getAllContentData('hydras', true) return getAllContentData('hydras', true)
......
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const livesDirectory = path.join(process.cwd(), 'content/lives');
export function getAllLives() {
const lives = [];
// Read all years
const years = fs.readdirSync(livesDirectory).filter(item =>
fs.statSync(path.join(livesDirectory, item)).isDirectory()
);
years.forEach(year => {
const yearPath = path.join(livesDirectory, year);
const yearFiles = fs.readdirSync(yearPath);
yearFiles.forEach(fileName => {
if (fileName.endsWith('.md')) {
const slug = fileName.replace(/\.md$/, '');
const fullPath = path.join(yearPath, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data } = matter(fileContents);
lives.push({
slug,
year,
...data,
});
}
});
});
// Sort by date, most recent first
return lives.sort((a, b) => new Date(b.date) - new Date(a.date));
}
export async function getLiveData(slug) {
// Find the file across all year directories
const years = fs.readdirSync(livesDirectory).filter(item =>
fs.statSync(path.join(livesDirectory, item)).isDirectory()
);
for (const year of years) {
const filePath = path.join(livesDirectory, year, `${slug}.md`);
if (fs.existsSync(filePath)) {
const fileContents = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug,
year,
frontmatter: data,
content,
};
}
}
throw new Error(`Live with slug "${slug}" not found`);
}
export function getLiveTracks(slug) {
const years = fs.readdirSync(livesDirectory).filter(item =>
fs.statSync(path.join(livesDirectory, item)).isDirectory()
);
for (const year of years) {
const tracksPath = path.join(livesDirectory, year, slug, 'tracks.json');
if (fs.existsSync(tracksPath)) {
return JSON.parse(fs.readFileSync(tracksPath, 'utf8'));
}
}
return null;
}
export function getLivesImages(slug) {
const years = fs.readdirSync(livesDirectory).filter(item =>
fs.statSync(path.join(livesDirectory, item)).isDirectory()
);
for (const year of years) {
const imagesPath = path.join(process.cwd(), 'public/images/parvagues/lives', year, slug);
if (fs.existsSync(imagesPath)) {
const files = fs.readdirSync(imagesPath);
return files
.filter(file => /\.(jpg|jpeg|png|gif|webp)$/i.test(file))
.map(file => `/images/parvagues/lives/${year}/${slug}/${file}`);
}
}
return [];
}
import {getAllContentData, getAllContentIds, getContentData} from './utils' import {getAllContentData, getAllContentIds, getContentData} from '@/lib/content'
export function getPoemsData(sortBy = 'tier', sortOrder = 'desc') { export function getPoemsData(sortBy = 'tier', sortOrder = 'desc') {
const poems = getAllContentData('poems', false); const poems = getAllContentData('poems', false);
......
import {getAllContentData, getAllContentIds, getContentData} from './utils' import {getAllContentData, getAllContentIds, getContentData} from '@/lib/content'
export function getPostsData() { export function getPostsData() {
return getAllContentData('posts', true) return getAllContentData('posts', true)
......
import {getAllContentData, getAllContentIds, getContentData} from './utils' import {getAllContentData, getAllContentIds, getContentData} from '@/lib/content'
export function getTalksData() { export function getTalksData() {
return getAllContentData('talks', true) return getAllContentData('talks', true)
......
import path from "path";
import fs from "fs";
import matter from "gray-matter";
import { remark } from 'remark';
import remarkHtml from 'remark-html';
function getContentDirectory(name) {
return path.join(process.cwd(), "content", name);
}
// Helper function to check if a file is a markdown file
function isMarkdownFile(fileName) {
return fileName.endsWith('.md') && !fileName.endsWith('.mdx.md');
}
// Helper function to get ID from filename
function getIdFromFileName(fileName) {
return fileName.replace(/\.md$/, '');
}
function getAllContentData(name, sorted = false) {
// Get file names under /content/{name}
const contentDirectory = getContentDirectory(name);
const fileNames = fs.readdirSync(contentDirectory);
console.log(`getAllContentData: Found ${fileNames.length} files in ${name}`);
const allContentData = fileNames
.filter(isMarkdownFile)
.map((fileName) => {
const id = getIdFromFileName(fileName);
console.log(`Processing ${fileName} with id ${id}`);
// Read markdown file as string
const fullPath = path.join(contentDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, "utf8");
// Use gray-matter to parse the post metadata section
const matterResult = matter(fileContents);
// Combine the data with the id
return {
id,
...matterResult.data,
};
});
return sorted
? allContentData.sort((a, b) => {
if (a.date < b.date) {
return 1;
} else {
return -1;
}
})
: allContentData;
}
function getAllContentIds(name) {
const fileNames = fs.readdirSync(getContentDirectory(name));
console.log(`getAllContentIds: Found ${fileNames.length} files in ${name}`);
return fileNames
.filter(isMarkdownFile)
.map((fileName) => ({
params: {
id: getIdFromFileName(fileName),
},
}));
}
async function getContentData(name, id) {
// Ensure we're looking for a .md file
const fileName = `${id}${id.endsWith('.md') ? '' : '.md'}`;
const fullPath = path.join(getContentDirectory(name), fileName);
if (!fs.existsSync(fullPath)) {
console.error(`File not found: ${fullPath}`);
throw new Error(`No content found for ${id}`);
}
console.log("Reading content from:", fullPath);
const fileContents = fs.readFileSync(fullPath, "utf8");
// Use gray-matter to parse the post metadata section
const matterResult = matter(fileContents);
// Use remark to convert markdown into HTML string
const processedContent = await remark()
.use(remarkHtml)
.process(matterResult.content);
const contentHtml = processedContent.toString();
if ("description" in matterResult.data) {
const processedDescription = await remark()
.use(remarkHtml)
.process(matterResult.data.description);
matterResult.data.description = processedDescription.toString();
}
// Combine the data with the id and contentHtml
return {
id: getIdFromFileName(fileName),
contentHtml,
...matterResult.data,
};
}
export {
getContentDirectory,
getContentData,
getAllContentData,
getAllContentIds,
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment