diff --git a/tools/add_lego.js b/tools/add_lego.js index d1032f40..01eaccbd 100644 --- a/tools/add_lego.js +++ b/tools/add_lego.js @@ -417,7 +417,7 @@ async function main() { const coverLine = coverFile ? `cover: images/${coverFile}\n` : ''; const md = `---\n` + `title: "${pageTitle.replace(/"/g, '\\"')}"\n` + - `date: ${createdAt}\n` + + `date: "${createdAt}"\n` + coverLine + `---\n\n` + body; diff --git a/tools/add_link.js b/tools/add_link.js index e09e8670..575f329a 100644 --- a/tools/add_link.js +++ b/tools/add_link.js @@ -133,7 +133,7 @@ if (duplicateBundlePath) { let content = fs.readFileSync(indexPath, "utf8"); // Inject date - content = content.replace(/^date: .*/m, `date: ${formattedEntryDate}`); + content = content.replace(/^date: .*/m, `date: "${formattedEntryDate}"`); // Inject status const statusEntry = `{"date": "${formattedStatusDate}", "http_code": ${metadata.httpStatus || "null"}}`; diff --git a/tools/lib/datetime.js b/tools/lib/datetime.js index c4bb91d1..8f766b47 100644 --- a/tools/lib/datetime.js +++ b/tools/lib/datetime.js @@ -32,6 +32,57 @@ function getHugoTimeZone() { return cachedTimeZone; } +/** + * Parse une chaîne de date selon les formats Hugo attendus. + * @param {string} value Chaîne de date. + * @param {string} zone Fuseau horaire IANA. + * @param {number} defaultHour Heure par défaut si absente. + * @param {number} defaultMinute Minute par défaut si absente. + * @returns {import("luxon").DateTime|null} DateTime ou null si invalide. + */ +function parseHugoDateString(value, zone, defaultHour = 12, defaultMinute = 0) { + let trimmed = value.trim(); + if (!trimmed) { + return null; + } + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) + ) { + trimmed = trimmed.slice(1, -1).trim(); + } + + const iso = DateTime.fromISO(trimmed, { setZone: true }); + if (iso.isValid) { + return iso.setZone(zone); + } + + const formats = [ + "yyyy-LL-dd HH:mm:ss", + "yyyy-LL-dd'T'HH:mm:ss", + "yyyy-LL-dd HH:mm", + "yyyy-LL-dd'T'HH:mm", + "yyyy-LL-dd", + ]; + + for (const format of formats) { + const parsed = DateTime.fromFormat(trimmed, format, { zone }); + if (parsed.isValid) { + if (format === "yyyy-LL-dd") { + return parsed.set({ hour: defaultHour, minute: defaultMinute, second: 0, millisecond: 0 }); + } + return parsed; + } + } + + const rfc2822 = DateTime.fromRFC2822(trimmed, { setZone: true }); + if (rfc2822.isValid) { + return rfc2822.setZone(zone); + } + + return null; +} + /** * Convertit une valeur vers un DateTime positionné sur le fuseau horaire Hugo. * @param {Date|import("luxon").DateTime|string|number|null} value Valeur à convertir (null : maintenant). @@ -65,9 +116,9 @@ function toHugoDateTime(value = null) { } if (typeof value === "string") { - const parsed = DateTime.fromISO(value, { setZone: true }).setZone(zone); - if (!parsed.isValid) { - throw new Error(parsed.invalidReason || `Chaîne de date invalide : ${value}`); + const parsed = parseHugoDateString(value, zone); + if (!parsed) { + throw new Error(`Chaîne de date invalide : ${value}`); } return parsed; } @@ -84,14 +135,14 @@ function toHugoDateTime(value = null) { } /** - * Formate une date en ISO 8601 avec l'offset du fuseau horaire Hugo. + * Formate une date selon le format Hugo simple (sans offset). * @param {Date|import("luxon").DateTime|string|number|null} value Valeur à formater. - * @returns {string} Timestamp ISO 8601 avec offset. + * @returns {string} Date formatée. */ function formatDateTime(value = null) { const zoned = toHugoDateTime(value); const normalized = zoned.set({ millisecond: 0 }); - const formatted = normalized.toISO({ suppressMilliseconds: true }); + const formatted = normalized.toFormat("yyyy-LL-dd HH:mm:ss"); if (!formatted) { throw new Error("Impossible de formater la date avec le fuseau Hugo."); @@ -119,16 +170,8 @@ function parseFrontmatterDate(value) { } if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const iso = DateTime.fromISO(trimmed, { setZone: true }).setZone(zone); - if (iso.isValid) { - return iso; - } - const rfc2822 = DateTime.fromRFC2822(trimmed, { setZone: true }).setZone(zone); - return rfc2822.isValid ? rfc2822 : null; + const parsed = parseHugoDateString(value, zone); + return parsed; } if (typeof value === "number" && Number.isFinite(value)) { diff --git a/tools/normalize_article_dates.js b/tools/normalize_article_dates.js new file mode 100644 index 00000000..f1ce2de4 --- /dev/null +++ b/tools/normalize_article_dates.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +const fs = require("fs/promises"); +const path = require("path"); +const { + extractRawDate, + readFrontmatter, + writeFrontmatter, +} = require("./lib/weather/frontmatter"); +const { parseFrontmatterDate, formatDateTime, getHugoTimeZone } = require("./lib/datetime"); + +const CONTENT_ROOT = path.join(process.cwd(), "content"); + +/** + * Liste récursivement tous les fichiers Markdown d'un dossier. + * @param {string} root Dossier racine à parcourir. + * @returns {Promise} Chemins absolus des fichiers trouvés. + */ +async function listMarkdownFiles(root) { + const entries = await fs.readdir(root, { withFileTypes: true }); + const results = []; + + for (const entry of entries) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + const nested = await listMarkdownFiles(fullPath); + results.push(...nested); + continue; + } + if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(fullPath); + } + } + + return results; +} + +/** + * Harmonise la date d'un fichier Markdown si nécessaire. + * @param {string} filePath Chemin absolu du fichier. + * @returns {Promise<"updated"|"unchanged"|"skipped"|"invalid">} Statut de traitement. + */ +async function normalizeFileDate(filePath) { + const frontmatter = await readFrontmatter(filePath); + if (!frontmatter) { + return "skipped"; + } + + const rawDate = extractRawDate(frontmatter.frontmatterText); + const dateValue = frontmatter.doc.get("date"); + + if (!rawDate && (dateValue === undefined || dateValue === null)) { + return "skipped"; + } + + const sourceValue = rawDate !== null ? rawDate : dateValue; + const parsed = parseFrontmatterDate(sourceValue); + if (!parsed) { + return "invalid"; + } + + let hasTime = false; + if (typeof rawDate === "string") { + const timeMatch = rawDate.match(/[T ](\d{2}):(\d{2})(?::(\d{2}))?/); + if (timeMatch) { + const hour = Number(timeMatch[1]); + const minute = Number(timeMatch[2]); + const second = Number(timeMatch[3] || "0"); + hasTime = !(hour === 0 && minute === 0 && second === 0); + } + } + const normalized = hasTime + ? parsed.set({ millisecond: 0 }) + : parsed.set({ hour: 12, minute: 0, second: 0, millisecond: 0 }); + const formatted = formatDateTime(normalized); + const current = typeof dateValue === "string" ? dateValue.trim() : rawDate; + const quotedFormatted = `"${formatted}"`; + const currentComparable = typeof current === "string" ? current.trim() : ""; + + if (currentComparable === formatted || currentComparable === quotedFormatted) { + return "unchanged"; + } + + frontmatter.doc.set("date", formatted); + await writeFrontmatter(filePath, frontmatter.doc, frontmatter.body); + const rewritten = await fs.readFile(filePath, "utf8"); + const normalizedContent = rewritten.replace(/^date:\s*.+$/m, `date: ${quotedFormatted}`); + if (rewritten !== normalizedContent) { + await fs.writeFile(filePath, normalizedContent, "utf8"); + } + return "updated"; +} + +/** + * Point d'entrée du script. + */ +async function main() { + const timezone = getHugoTimeZone(); + console.log(`Fuseau horaire Hugo : ${timezone}`); + + const files = await listMarkdownFiles(CONTENT_ROOT); + let updated = 0; + let unchanged = 0; + let skipped = 0; + let invalid = 0; + + for (const file of files) { + const status = await normalizeFileDate(file); + if (status === "updated") updated += 1; + else if (status === "unchanged") unchanged += 1; + else if (status === "invalid") { + invalid += 1; + const relative = path.relative(process.cwd(), file); + console.warn(`Date invalide : ${relative}`); + } else { + skipped += 1; + } + } + + console.log( + `Terminé. ${updated} mis à jour, ${unchanged} inchangés, ${skipped} ignorés, ${invalid} invalides.` + ); +} + +main();