From e5f7af48cb3504fd62b3b73e7f4cae287d82c5db Mon Sep 17 00:00:00 2001 From: James Brumond Date: Sat, 20 May 2023 19:08:43 -0700 Subject: [PATCH] support for rss/calendar --- package-lock.json | 234 +++++++++++++++++++++++++++++++++- package.json | 3 + src/build-files/helpers.ts | 162 ++++++++++++++++------- src/build-files/icalendar.ts | 131 +++++++++++++++++++ src/build-files/index.ts | 9 +- src/build-files/jsonschema.ts | 13 +- src/build-files/markdown.ts | 7 +- src/build-files/mustache.ts | 7 +- src/build-files/rss.ts | 131 +++++++++++++++++++ src/build-files/state.ts | 10 +- src/conf.ts | 58 +++++++-- src/metadata.ts | 22 ++-- src/template.ts | 31 ++++- src/time.ts | 54 ++++++++ 14 files changed, 782 insertions(+), 90 deletions(-) create mode 100644 src/build-files/icalendar.ts create mode 100644 src/build-files/rss.ts create mode 100644 src/time.ts diff --git a/package-lock.json b/package-lock.json index 1466d0f..704c204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@doc-utils/jsonschema2markdown": "^0.1.1", "@doc-utils/markdown2html": "^0.2.1", "glob": "^10.2.3", + "ical": "^0.8.0", + "ical-generator": "^4.1.0", "luxon": "^3.3.0", "mustache": "^4.2.0", "xmlbuilder2": "^3.1.1", @@ -21,6 +23,7 @@ "docs2website": "bin/docs2website" }, "devDependencies": { + "@types/ical": "^0.8.0", "@types/jsdom": "^20.0.1", "@types/luxon": "^3.3.0", "@types/mustache": "^4.2.2", @@ -152,6 +155,43 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.4.tgz", "integrity": "sha512-MHmwBtCb7OCv1DSivz2UNJXPGU/1btAWRKlqJ2saEhVJkpkvqHMMaOpKg0v4sAbDWSQekHGvPVMM8nQ+Jen03Q==" }, + "node_modules/@types/ical": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@types/ical/-/ical-0.8.0.tgz", + "integrity": "sha512-46KAYxAwRuCh+jRgl9k5cTaXJJkB16gWpvMVPuog9UBBb2zXTf9M0MsfWYBQ21JohFSuMZfPTvk6tohqGr1tCg==", + "dev": true, + "dependencies": { + "rrule": "2.6.4" + } + }, + "node_modules/@types/ical/node_modules/luxon": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz", + "integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==", + "dev": true, + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/@types/ical/node_modules/rrule": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.4.tgz", + "integrity": "sha512-sLdnh4lmjUqq8liFiOUXD5kWp/FcnbDLPwq5YAc/RrN6120XOPb86Ae5zxF7ttBVq8O3LxjjORMEit1baluahA==", + "dev": true, + "dependencies": { + "tslib": "^1.10.0" + }, + "optionalDependencies": { + "luxon": "^1.21.3" + } + }, + "node_modules/@types/ical/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -167,7 +207,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.0.tgz", "integrity": "sha512-uKRI5QORDnrGFYgcdAVnHvEIvEZ8noTpP/Bg+HeUzZghwinDlIS87DEenV5r1YoOF9G4x600YsUXLWZ19rmTmg==", - "dev": true + "devOptional": true }, "node_modules/@types/mustache": { "version": "4.2.2", @@ -179,7 +219,7 @@ "version": "18.16.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.13.tgz", "integrity": "sha512-uZRomboV1vBL61EBXneL4j9/hEn+1Yqa4LQdpGrKmXFyJmVfWc9JV9+yb2AlnOnuaDnb2PDO3hC6/LKmzJxP1A==", - "dev": true + "devOptional": true }, "node_modules/@types/prismjs": { "version": "1.26.0", @@ -1061,6 +1101,82 @@ "node": ">= 6" } }, + "node_modules/ical": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/ical/-/ical-0.8.0.tgz", + "integrity": "sha512-/viUSb/RGLLnlgm0lWRlPBtVeQguQRErSPYl3ugnUaKUnzQswKqOG3M8/P1v1AB5NJwlHTuvTq1cs4mpeG2rCg==", + "dependencies": { + "rrule": "2.4.1" + } + }, + "node_modules/ical-generator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-4.1.0.tgz", + "integrity": "sha512-5GrFDJ8SAOj8cB9P1uEZIfKrNxSZ1R2eOQfZePL+CtdWh4RwNXWe8b0goajz+Hu37vcipG3RVldoa2j57Y20IA==", + "dependencies": { + "uuid-random": "^1.3.2" + }, + "engines": { + "node": "^14.8.0 || >=16.0.0" + }, + "peerDependencies": { + "@touch4it/ical-timezones": ">=1.6.0", + "@types/luxon": ">= 1.26.0", + "@types/mocha": ">= 8.2.1", + "@types/node": ">= 15.0.0", + "dayjs": ">= 1.10.0", + "luxon": ">= 1.26.0", + "moment": ">= 2.29.0", + "moment-timezone": ">= 0.5.33", + "rrule": ">= 2.6.8" + }, + "peerDependenciesMeta": { + "@touch4it/ical-timezones": { + "optional": true + }, + "@types/luxon": { + "optional": true + }, + "@types/mocha": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-timezone": { + "optional": true + }, + "rrule": { + "optional": true + } + } + }, + "node_modules/ical/node_modules/luxon": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz", + "integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/ical/node_modules/rrule": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.4.1.tgz", + "integrity": "sha512-+NcvhETefswZq13T8nkuEnnQ6YgUeZaqMqVbp+ZiFDPCbp3AVgQIwUvNVDdMNrP05bKZG9ddDULFp0qZZYDrxg==", + "optionalDependencies": { + "luxon": "^1.3.3" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -1545,6 +1661,16 @@ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.1.tgz", "integrity": "sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==" }, + "node_modules/rrule": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.7.2.tgz", + "integrity": "sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -1792,6 +1918,13 @@ "node": ">=12" } }, + "node_modules/tslib": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz", + "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==", + "optional": true, + "peer": true + }, "node_modules/type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -1841,6 +1974,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/uuid-random": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/uuid-random/-/uuid-random-1.3.2.tgz", + "integrity": "sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==" + }, "node_modules/vega": { "version": "5.25.0", "resolved": "https://registry.npmjs.org/vega/-/vega-5.25.0.tgz", @@ -2637,6 +2775,40 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.4.tgz", "integrity": "sha512-MHmwBtCb7OCv1DSivz2UNJXPGU/1btAWRKlqJ2saEhVJkpkvqHMMaOpKg0v4sAbDWSQekHGvPVMM8nQ+Jen03Q==" }, + "@types/ical": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@types/ical/-/ical-0.8.0.tgz", + "integrity": "sha512-46KAYxAwRuCh+jRgl9k5cTaXJJkB16gWpvMVPuog9UBBb2zXTf9M0MsfWYBQ21JohFSuMZfPTvk6tohqGr1tCg==", + "dev": true, + "requires": { + "rrule": "2.6.4" + }, + "dependencies": { + "luxon": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz", + "integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==", + "dev": true, + "optional": true + }, + "rrule": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.4.tgz", + "integrity": "sha512-sLdnh4lmjUqq8liFiOUXD5kWp/FcnbDLPwq5YAc/RrN6120XOPb86Ae5zxF7ttBVq8O3LxjjORMEit1baluahA==", + "dev": true, + "requires": { + "luxon": "^1.21.3", + "tslib": "^1.10.0" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } + } + }, "@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -2652,7 +2824,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.0.tgz", "integrity": "sha512-uKRI5QORDnrGFYgcdAVnHvEIvEZ8noTpP/Bg+HeUzZghwinDlIS87DEenV5r1YoOF9G4x600YsUXLWZ19rmTmg==", - "dev": true + "devOptional": true }, "@types/mustache": { "version": "4.2.2", @@ -2664,7 +2836,7 @@ "version": "18.16.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.13.tgz", "integrity": "sha512-uZRomboV1vBL61EBXneL4j9/hEn+1Yqa4LQdpGrKmXFyJmVfWc9JV9+yb2AlnOnuaDnb2PDO3hC6/LKmzJxP1A==", - "dev": true + "devOptional": true }, "@types/prismjs": { "version": "1.26.0", @@ -3307,6 +3479,38 @@ "debug": "4" } }, + "ical": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/ical/-/ical-0.8.0.tgz", + "integrity": "sha512-/viUSb/RGLLnlgm0lWRlPBtVeQguQRErSPYl3ugnUaKUnzQswKqOG3M8/P1v1AB5NJwlHTuvTq1cs4mpeG2rCg==", + "requires": { + "rrule": "2.4.1" + }, + "dependencies": { + "luxon": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz", + "integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==", + "optional": true + }, + "rrule": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.4.1.tgz", + "integrity": "sha512-+NcvhETefswZq13T8nkuEnnQ6YgUeZaqMqVbp+ZiFDPCbp3AVgQIwUvNVDdMNrP05bKZG9ddDULFp0qZZYDrxg==", + "requires": { + "luxon": "^1.3.3" + } + } + } + }, + "ical-generator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-4.1.0.tgz", + "integrity": "sha512-5GrFDJ8SAOj8cB9P1uEZIfKrNxSZ1R2eOQfZePL+CtdWh4RwNXWe8b0goajz+Hu37vcipG3RVldoa2j57Y20IA==", + "requires": { + "uuid-random": "^1.3.2" + } + }, "iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3650,6 +3854,16 @@ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.1.tgz", "integrity": "sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==" }, + "rrule": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.7.2.tgz", + "integrity": "sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==", + "optional": true, + "peer": true, + "requires": { + "tslib": "^2.4.0" + } + }, "rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -3835,6 +4049,13 @@ "punycode": "^2.1.1" } }, + "tslib": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz", + "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==", + "optional": true, + "peer": true + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -3868,6 +4089,11 @@ "requires-port": "^1.0.0" } }, + "uuid-random": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/uuid-random/-/uuid-random-1.3.2.tgz", + "integrity": "sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==" + }, "vega": { "version": "5.25.0", "resolved": "https://registry.npmjs.org/vega/-/vega-5.25.0.tgz", diff --git a/package.json b/package.json index 951a5fa..d7cfdf8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "main": "./build/index.js", "devDependencies": { + "@types/ical": "^0.8.0", "@types/jsdom": "^20.0.1", "@types/luxon": "^3.3.0", "@types/mustache": "^4.2.2", @@ -25,6 +26,8 @@ "@doc-utils/jsonschema2markdown": "^0.1.1", "@doc-utils/markdown2html": "^0.2.1", "glob": "^10.2.3", + "ical": "^0.8.0", + "ical-generator": "^4.1.0", "luxon": "^3.3.0", "mustache": "^4.2.0", "xmlbuilder2": "^3.1.1", diff --git a/src/build-files/helpers.ts b/src/build-files/helpers.ts index 2c75a68..5aabead 100644 --- a/src/build-files/helpers.ts +++ b/src/build-files/helpers.ts @@ -1,12 +1,15 @@ import { dirname, join as path_join } from 'path'; -import { mkdirp, write_text } from '../fs'; import { icons } from '../icons'; import { BuildState } from './state'; -import { load_partials, FrontMatter, Context, load_layout, render_template } from '../template'; -import { render_theme_css_properties } from '../themes'; -import { render_markdown_to_html, render_markdown_to_html_inline_sync } from '@doc-utils/markdown2html'; +import { mkdirp, write_text } from '../fs'; import { CalendarConfig, RSSConfig } from '../conf'; +import { render_theme_css_properties } from '../themes'; +import { load_partials, FrontMatter, Context, load_layout, render_template } from '../template'; +import { render_markdown_to_html, render_markdown_to_html_inline_sync } from '@doc-utils/markdown2html'; +import { RSSEntry } from './rss'; +import { DateTime } from 'luxon'; +import { EventEntry } from './icalendar'; export interface OutFileURL { base_url: string; @@ -84,8 +87,12 @@ export function mustache_context(state: BuildState, page_url: string, frontmatte page: frontmatter, base_url: state.conf.base_url, page_url: page_url, + site_title: state.conf.title, + author: get_author(state, frontmatter), build_time: state.build_time, icons: icons, + rss_feeds: state.conf.rss || [ ], + calendars: state.conf.calendars || [ ], themes: Object.values(state.themes), theme_groups: structuredClone(state.theme_groups), markdown: { @@ -102,7 +109,7 @@ export function mustache_context(state: BuildState, page_url: string, frontmatte }; } -export async function render_page(state: BuildState, out_file: string, out_url: OutFileURL, text: string, render_as_markdown: boolean, frontmatter?: any) { +export async function render_page(state: BuildState, in_file: string, out_file: string, out_url: OutFileURL, text: string, render_as_markdown: boolean, frontmatter?: any) { if (render_as_markdown) { const opts = Object.assign({ }, state.conf.markdown, { base_url: out_url.abs_url @@ -126,70 +133,108 @@ export async function render_page(state: BuildState, out_file: string, out_url: const context = mustache_context(state, out_url.abs_url, frontmatter); const rendered = render_template(text, context, layout, structuredClone(state.partials), tags); await write_text(out_file, rendered); - - handle_page_side_effects(state, out_file, out_url, frontmatter); + + handle_page_side_effects(state, in_file, out_file, out_url, text, frontmatter); } -function handle_page_side_effects(state: BuildState, out_file: string, out_url: OutFileURL, frontmatter?: any) { +function handle_page_side_effects(state: BuildState, in_file: string, out_file: string, out_url: OutFileURL, text: string, frontmatter?: any) { // Only actual HTML webpages are registered with things like the RSS feed; This is // to prevent things like CSS files showing up, which may be templated (and therefore // pass through this function), but are not really "pages" if (frontmatter && typeof frontmatter === 'object' && out_file.endsWith('.html')) { if (state.conf.rss) { - if (Array.isArray(state.conf.rss)) { - for (const rss_conf of state.conf.rss) { - handle_rss(state, rss_conf, out_url, frontmatter); + for (const [index, rss_conf] of Object.entries(state.conf.rss)) { + if (in_file.startsWith(rss_conf.in_dir + '/')) { + const entries = (state.rss[index] = state.rss[index] || [ ]); + handle_rss(state, rss_conf, entries, in_file, out_url, text, frontmatter); } } - - else { - handle_rss(state, { }, out_url, frontmatter); - } } - handle_sitemap(state, out_url, frontmatter); - handle_event(state, out_url, frontmatter); + if (state.conf.sitemap) { + handle_sitemap(state, out_url, frontmatter); + } - if (state.conf.calendars) { - for (const cal_conf of state.conf.calendars) { - handle_calendar(state, cal_conf, out_url, frontmatter); + if (frontmatter?.event) { + if (state.conf.events) { + handle_event(state, in_file, out_url, frontmatter); + } + + if (state.conf.calendars) { + for (const [index, cal_conf] of Object.entries(state.conf.calendars)) { + if (in_file.startsWith(cal_conf.in_dir + '/')) { + const entries = (state.rss[index] = state.rss[index] || [ ]); + handle_calendar(state, cal_conf, entries, in_file, out_url, frontmatter); + } + } } } } } -function handle_rss(state: BuildState, rss_conf: RSSConfig, out_url: OutFileURL, frontmatter: any) { - // const field = rss_conf - // const rss_frontmatter = state. - // // -} +function handle_rss(state: BuildState, rss_conf: RSSConfig, entries: RSSEntry[], in_file: string, out_url: OutFileURL, text: string, frontmatter: FrontMatter) { + const author_or_authors = get_author(state, frontmatter); + const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors; -function handle_sitemap(state: BuildState, out_url: OutFileURL, frontmatter: any) { - if (! state.conf.sitemap) { - return; - } - - const sitemap_frontmatter = state.conf.sitemap.front_matter_field - ? frontmatter?.[state.conf.sitemap.front_matter_field] || { } - : frontmatter?.sitemap || { }; - - state.sitemap.push({ + entries.push({ url: out_url.abs_url, - lastmod: state.build_time.iso, - change_freq: sitemap_frontmatter?.change_freq, - priority: sitemap_frontmatter?.priority, + in_file: in_file, + html_content: text, + title: frontmatter?.title, + description: frontmatter?.description, + author_name: author?.name, + tags: frontmatter?.tags, }); } -function handle_event(state: BuildState, out_url: OutFileURL, frontmatter: any) { - // +function handle_sitemap(state: BuildState, out_url: OutFileURL, frontmatter: FrontMatter) { + state.sitemap.push({ + url: out_url.abs_url, + lastmod: state.build_time.iso, + change_freq: frontmatter?.sitemap?.change_freq, + priority: frontmatter?.sitemap?.priority, + }); } -function handle_calendar(state: BuildState, cal_conf: CalendarConfig, out_url: OutFileURL, frontmatter: any) { - // +function handle_event(state: BuildState, in_file: string, out_url: OutFileURL, frontmatter: FrontMatter) { + if (! state.conf.events) { + return; + } + + const author_or_authors = get_author(state, frontmatter); + const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors; + + state.events.push({ + url: out_url.abs_url, + in_file: in_file, + title: frontmatter.title, + description: frontmatter.description, + author_name: author?.name, + author_email: author?.email, + start_time: frontmatter.event?.start_time, + end_time: frontmatter.event?.end_time, + time_zone: frontmatter.event?.time_zone, + }); } -export function file_hash_matches(state: BuildState, in_file: string, new_hash: string) { +function handle_calendar(state: BuildState, cal_conf: CalendarConfig, entries: EventEntry[], in_file: string, out_url: OutFileURL, frontmatter: FrontMatter) { + const author_or_authors = get_author(state, frontmatter); + const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors; + + entries.push({ + url: out_url.abs_url, + in_file: in_file, + title: frontmatter.title, + description: frontmatter.description, + author_name: author?.name, + author_email: author?.email, + start_time: frontmatter.event?.start_time, + end_time: frontmatter.event?.end_time, + time_zone: frontmatter.event?.time_zone, + }); +} + +export function config_hash_matches(state: BuildState) { const { old_metadata, new_metadata } = state; if (! old_metadata.last_build?.config_hash) { @@ -199,7 +244,11 @@ export function file_hash_matches(state: BuildState, in_file: string, new_hash: if (old_metadata.last_build.config_hash !== new_metadata.last_build.config_hash) { return false; } + + return true; +} +export function file_hash_matches(state: BuildState, in_file: string, new_hash: string) { in_file = in_file.slice(state.conf.input.root.length); const old_hash = state.old_metadata.files[in_file]?.last_build_hash; @@ -217,7 +266,12 @@ export function file_hash_matches(state: BuildState, in_file: string, new_hash: export function skip_file(state: BuildState, in_file: string, out_file: string, out_url: OutFileURL, frontmatter?: any) { in_file = in_file.slice(state.conf.input.root.length); state.new_metadata.files[in_file] = structuredClone(state.old_metadata?.files?.[in_file]); - handle_page_side_effects(state, out_file, out_url, frontmatter); + handle_page_side_effects(state, in_file, out_file, out_url, frontmatter); +} + +export function copy_metadata(state: BuildState, in_file: string) { + in_file = in_file.slice(state.conf.input.root.length); + state.new_metadata.files[in_file] = structuredClone(state.old_metadata?.files?.[in_file]); } export function update_metadata(state: BuildState, in_file: string, hash: string) { @@ -228,3 +282,23 @@ export function update_metadata(state: BuildState, in_file: string, hash: string last_updated_time: state.build_time.iso, }; } + +export function get_author(state: BuildState, frontmatter?: FrontMatter) { + if (! frontmatter?.author) { + return null; + } + + if (Array.isArray(frontmatter.author)) { + const list = frontmatter.author + .map((author) => state.conf.authors?.[author]) + .filter((author) => author); + + if (! list.length) { + return null; + } + + return list; + } + + return state.conf.authors?.[frontmatter.author] +} diff --git a/src/build-files/icalendar.ts b/src/build-files/icalendar.ts new file mode 100644 index 0000000..7a1f4b6 --- /dev/null +++ b/src/build-files/icalendar.ts @@ -0,0 +1,131 @@ + +import { write_text } from '../fs'; +import { BuildState } from './state'; +import { map_input_file_to_output_file } from './helpers'; +import { parseICS, CalendarComponent } from 'ical'; +import create_calendar, { ICalEventData, ICalCalendarData } from 'ical-generator'; + +export type { ICalEventData, ICalCalendarData, ICalAttendeeData, ICalAttendeeStatus } from 'ical-generator'; + +export interface EventEntry { + url: string; + in_file: string; + title?: string; + description?: string; + author_name?: string; + author_email?: string; + start_time?: string; + end_time?: string; + time_zone?: `${string}/${string}`; +} + +export async function write_events_and_calendars_if_needed(state: BuildState) { + if (state.conf.events) { + for (const entry of state.events) { + const event = icalendar_event(state, entry); + const cal_data: ICalCalendarData = { + prodId: { + company: 'jbrumond.me', + product: 'docs2website', + language: 'EN', + }, + // ... + }; + + const calendar = create_icalendar(cal_data, event); + const out_file = await map_input_file_to_output_file(state, entry.in_file, [ '.html', '.md', '.markdown' ], '.isc'); + await write_text(out_file, calendar); + } + } + + if (state.conf.calendars) { + for (let index = 0; index < state.conf.calendars.length; index++) { + const cal_conf = state.conf.calendars[index]; + const cal_entries = state.calendars[index]; + + for (const entry of cal_entries) { + const event = icalendar_event(state, entry); + const cal_data: ICalCalendarData = { + name: cal_conf.title, + prodId: { + company: 'jbrumond.me', + product: 'docs2website', + language: 'EN', + }, + // ... + }; + + const calendar = create_icalendar(cal_data, event); + await write_text(cal_conf.out_file, calendar); + } + } + } +} + +export function parse_icalendar(contents: string) { + const parsed = parseICS(contents); + const calendar: CalendarComponent[] = [ ]; + + for (const data of Object.values(parsed)) { + calendar.push(data); + } + + return calendar; +} + +export function create_icalendar(cal: ICalCalendarData, events: ICalEventData | ICalEventData[]) { + const calendar = create_calendar(cal); + + if (Array.isArray(events)) { + for (const event of events) { + calendar.createEvent(event); + } + } + + else { + calendar.createEvent(events); + } + + return calendar.toString(); +} + +export function icalendar_event(state: BuildState, entry: EventEntry) : ICalEventData { + const in_file = entry.in_file.slice(state.conf.input.root.length); + const metadata = state.new_metadata.files[in_file]; + + return { + id: entry.url, + summary: entry.title, + description: entry.description || void 0, + start: entry.start_time, + end: entry.end_time, + url: entry.url, + timezone: entry.time_zone, + created: metadata.first_seen_time, + lastModified: metadata.last_updated_time, + organizer: { + name: entry.author_name || 'Unknown', + email: entry.author_email, + }, + // attendees: post.mentions.flatMap((mention) : ICalAttendeeData | ICalAttendeeData[] => { + // if (mention.is_rsvp && mention.is_reply_to_this) { + // const ext = mention.external as ExternalEntry; + // const status = ext.rsvp_type === 'yes' + // ? 'ACCEPTED' as const + // : ext.rsvp_type === 'no' + // ? 'DECLINED' as const + // : ext.rsvp_type === 'maybe' + // ? 'TENTATIVE' as const + // : 'NEEDS-ACTION' as const; + + // return { + // name: mention.author_name, + // rsvp: ext.rsvp_type === 'yes', + // status: status as ICalAttendeeStatus + // }; + // } + + // return [ ]; + // }), + }; +} diff --git a/src/build-files/index.ts b/src/build-files/index.ts index 7dedf7e..dea76a1 100644 --- a/src/build-files/index.ts +++ b/src/build-files/index.ts @@ -14,6 +14,8 @@ import { render_text_file_templates } from './mustache'; import { render_markdown_files } from './markdown'; import { render_json_schema_files } from './jsonschema'; import { write_sitemap_if_needed } from './sitemap'; +import { write_rss_if_needed } from './rss'; +import { write_events_and_calendars_if_needed } from './icalendar'; export { BuildState, ThemeGroups } from './state'; @@ -55,7 +57,10 @@ export async function build_docs_project(conf: Config) { }, extras: await load_extras(), made_directories: new Set(), + rss: [ ], sitemap: [ ], + events: [ ], + calendars: [ ], build_time: { iso: now.toISO(), rfc2822: now.toRFC2822(), @@ -87,8 +92,8 @@ export async function build_docs_project(conf: Config) { // todo: other file types... await write_sitemap_if_needed(state); - // todo: rss - // todo: events + await write_rss_if_needed(state); + await write_events_and_calendars_if_needed(state); // Write the updated metadata file await write_json(conf.metadata, state.new_metadata, true); diff --git a/src/build-files/jsonschema.ts b/src/build-files/jsonschema.ts index 60cd889..a4ed44c 100644 --- a/src/build-files/jsonschema.ts +++ b/src/build-files/jsonschema.ts @@ -4,7 +4,7 @@ import { BuildState } from './state'; import { read_json, write_text, read_yaml } from '../fs'; import { build_markdown_from_json_schema } from '@doc-utils/jsonschema2markdown'; import { stringify as to_yaml } from 'yaml'; -import { build_partials, file_hash_matches, map_input_file_to_output_file, map_output_file_to_url, render_page, skip_file, update_metadata } from './helpers'; +import { build_partials, copy_metadata, file_hash_matches, map_input_file_to_output_file, map_output_file_to_url, render_page, skip_file, update_metadata } from './helpers'; export async function render_json_schema_files(state: BuildState) { const promises: Promise[] = [ ]; @@ -113,10 +113,6 @@ export async function render_json_schema(state: BuildState, schema: unknown, in_ const out_url = map_output_file_to_url(state, out_file); - if (file_hash_matches(state, in_file, hash)) { - return skip_file(state, in_file, out_file, out_url, frontmatter); - } - const promises: Promise[] = [ ]; const markdown = build_markdown_from_json_schema(schema); @@ -128,9 +124,14 @@ export async function render_json_schema(state: BuildState, schema: unknown, in_ } promises.push( - render_page(state, out_file, out_url, markdown, true, frontmatter) + render_page(state, in_file, out_file, out_url, markdown, true, frontmatter) ); await Promise.all(promises); + + if (file_hash_matches(state, in_file, hash)) { + return copy_metadata(state, in_file); + } + update_metadata(state, in_file, hash); } diff --git a/src/build-files/markdown.ts b/src/build-files/markdown.ts index 09455fd..ee0a1c0 100644 --- a/src/build-files/markdown.ts +++ b/src/build-files/markdown.ts @@ -2,7 +2,7 @@ import { glob } from 'glob'; import { read_text } from '../fs'; import { BuildState } from './state'; -import { build_partials, file_hash_matches, map_input_file_to_output_file, map_output_file_to_url, render_page, skip_file, update_metadata } from './helpers'; +import { build_partials, copy_metadata, file_hash_matches, map_input_file_to_output_file, map_output_file_to_url, render_page, skip_file, update_metadata } from './helpers'; export async function render_markdown_files(state: BuildState) { const promises: Promise[] = [ ]; @@ -38,11 +38,12 @@ export async function render_markdown_file(state: BuildState, in_file: string) { if (frontmatter?.skip) { return; } + + await render_page(state, in_file, out_file, out_url, text, true, frontmatter); if (file_hash_matches(state, in_file, hash)) { - return skip_file(state, in_file, out_file, out_url, frontmatter); + return copy_metadata(state, in_file); } - await render_page(state, out_file, out_url, text, true, frontmatter); update_metadata(state, in_file, hash); } diff --git a/src/build-files/mustache.ts b/src/build-files/mustache.ts index bb94c6a..51e028e 100644 --- a/src/build-files/mustache.ts +++ b/src/build-files/mustache.ts @@ -2,7 +2,7 @@ import { glob } from 'glob'; import { read_text } from '../fs'; import { BuildState } from './state'; -import { build_partials, file_hash_matches, map_input_file_to_output_file, map_output_file_to_url, render_page, skip_file, update_metadata } from './helpers'; +import { build_partials, copy_metadata, file_hash_matches, map_input_file_to_output_file, map_output_file_to_url, render_page, skip_file, update_metadata } from './helpers'; export async function render_text_file_templates(state: BuildState) { const promises: Promise[] = [ ]; @@ -39,10 +39,11 @@ export async function render_text_file_template(state: BuildState, in_file: stri return; } + await render_page(state, in_file, out_file, out_url, text, false, frontmatter); + if (file_hash_matches(state, in_file, hash)) { - return skip_file(state, in_file, out_file, out_url, frontmatter); + return copy_metadata(state, in_file); } - await render_page(state, out_file, out_url, text, false, frontmatter); update_metadata(state, in_file, hash); } diff --git a/src/build-files/rss.ts b/src/build-files/rss.ts new file mode 100644 index 0000000..a5c2c97 --- /dev/null +++ b/src/build-files/rss.ts @@ -0,0 +1,131 @@ + +import type { XMLBuilder } from 'xmlbuilder2/lib/interfaces'; +import { create as create_xml } from 'xmlbuilder2'; +import { BuildState } from './state'; +import { write_text } from '../fs'; +import { app_version } from '../conf'; +import { map_output_file_to_url } from './helpers'; +import { DateTime } from 'luxon'; + +export interface RSSEntry { + url: string; + in_file: string; + title?: string; + description?: string; + author_name?: string; + html_content?: string; + tags?: string[]; +} + +export async function write_rss_if_needed(state: BuildState) { + if (! state.conf.rss) { + return; + } + + for (let index = 0; index < state.conf.rss.length; index++) { + const rss_conf = state.conf.rss[index]; + const doc = create_xml({ version: '1.0', encoding: 'UTF-8' }); + const { abs_url: self_url } = map_output_file_to_url(state, rss_conf.out_file); + + if (rss_conf.xsl) { + for (const url of rss_conf.xsl) { + doc.ins('xml-stylesheet', `href="${url}" type="text/xsl"`); + } + } + + const rss = doc.ele('rss', { + version: '2.0', + 'xmlns:dc': 'http://purl.org/dc/elements/1.1/', + 'xmlns:content': 'http://purl.org/rss/1.0/modules/content/', + 'xmlns:atom': 'http://www.w3.org/2005/Atom', + // 'xmlns:docs2website': 'urn:uuid:7fc4e5d4-f68e-11ed-b0d0-00155ddef564', + }); + + const channel = rss.ele('channel'); + channel.ele('generator').txt(`docs2website ${app_version}`); + + if (rss_conf.title) { + channel.ele('title').txt(rss_conf.title); + } + + if (rss_conf.link) { + channel.ele('link').txt(rss_conf.link); + } + + link(channel, 'application/rss+xml', 'self', self_url); + + if (rss_conf.alternates?.length) { + for (const alt of rss_conf.alternates) { + link(channel, alt.type, 'alternate', alt.href); + } + } + + if (rss_conf.description) { + channel.ele('description').txt(rss_conf.description); + } + + if (rss_conf.language) { + channel.ele('language').txt(rss_conf.language); + } + + if (rss_conf.copyright) { + channel.ele('copyright').txt(rss_conf.copyright); + } + + channel.ele('lastBuildDate').txt(state.build_time.rfc2822); + + // channel.ele('rating').txt(''); // see: https://www.w3.org/PICS/ + // channel.ele('pubDate').txt(publish_date.toUTCString()); + // channel.ele('categories').txt(''); + // channel.ele('docs').txt(''); + // channel.ele('managingEditor').txt('james@jbrumond.me'); + // channel.ele('webMaster').txt('james@jbrumond.me'); + // channel.ele('ttl').txt(''); + // channel.ele('image').txt(''); + // channel.ele('skipHours').txt(''); + // channel.ele('skipDays').txt(''); + + for (const entry of state.rss[index] || [ ]) { + const item = channel.ele('item'); + const in_file = entry.in_file.slice(state.conf.input.root.length); + const metadata = state.new_metadata.files[in_file]; + + item.ele('link').txt(entry.url); + + if (entry.title) { + item.ele('title').txt(entry.title); + } + + if (entry.description) { + item.ele('description').txt(entry.description); + } + + if (entry.author_name) { + item.ele('dc:creator').ele({ $: entry.author_name }); + } + + if (entry.tags) { + entry.tags.forEach((tag) => item.ele('category').txt(tag)); + } + + item.ele('guid').txt(entry.url); + item.ele('pubDate').txt(DateTime.fromISO(metadata.first_seen_time).toRFC2822()); + + if (entry.html_content) { + item.ele('content:encoded').ele({ $: entry.html_content }); + } + } + + const xml = doc.toString({ + indent: ' ', + prettyPrint: true, + }); + + await write_text(rss_conf.out_file, xml); + } +} + +function link(channel: XMLBuilder, type: string, rel: string, href: string) { + // channel.ele('link', { href, rel, type, }); + channel.ele('atom:link', { type, rel, href, }); +} diff --git a/src/build-files/state.ts b/src/build-files/state.ts index 9e8943d..130c4ff 100644 --- a/src/build-files/state.ts +++ b/src/build-files/state.ts @@ -3,6 +3,8 @@ import type { Config } from '../conf'; import type { Metadata } from '../metadata'; import type { ColorTheme } from '@doc-utils/color-themes'; import type { SitemapEntry } from './sitemap'; +import { RSSEntry } from './rss'; +import { EventEntry } from './icalendar'; export interface BuildState { conf: Config; @@ -16,12 +18,10 @@ export interface BuildState { made_directories: Set; old_metadata: Metadata; new_metadata: Metadata; - // rss: { - // url: string; - // last_updated: string; - // }[]; + rss: RSSEntry[][]; sitemap: SitemapEntry[]; - // events: EventEntry[]; + events: EventEntry[]; + calendars: EventEntry[][]; build_time: { iso: string; rfc2822: string; diff --git a/src/conf.ts b/src/conf.ts index e36590c..e8d663d 100644 --- a/src/conf.ts +++ b/src/conf.ts @@ -4,31 +4,59 @@ import { parse as parse_yaml } from 'yaml'; import { resolve as resolve_path, dirname } from 'path'; import { MarkdownOptions } from '@doc-utils/markdown2html'; +export const app_version = require('../package.json').version; + export async function read_config(file: string) { const path = resolve_path(process.cwd(), file); const yaml = await fs.readFile(path, 'utf8'); const config = parse_yaml(yaml); + validate_config(config); + + config.title = resolve_env_var_if_needed(config.title); + config.metadata = resolve_env_var_if_needed(config.metadata); + config.base_url = resolve_env_var_if_needed(config.base_url); + config.input.root = resolve_env_var_if_needed(config.input.root); + config.output.root = resolve_env_var_if_needed(config.output.root); + process_markdown_config(config); resolve_paths(path, config); + return config; } export interface RSSConfig { - dir_path?: string; - out_file?: string; - front_matter_field?: string; + in_dir: string; + out_file: string; + title?: string; + description?: string; + link?: string; + xsl?: string[]; + language?: string; + copyright?: string; + alternates?: { + type: string; + href: string; + }[]; } export interface CalendarConfig { - dir_path?: string; + in_dir?: string; out_file?: string; - front_matter_field?: string; + title?: string; +} + +export interface AuthorConfig { + name?: string; + email?: string; + url?: string; } export interface Config { + title: string; metadata: string; base_url: string; + authors?: Record; input: { root: string; raw?: string[]; @@ -55,11 +83,9 @@ export interface Config { }; sitemap?: false | { out_file?: string; - front_matter_field?: string; }; rss?: false | RSSConfig[]; events?: false | { - front_matter_field?: string; }; calendars?: false | CalendarConfig[]; markdown?: Omit; @@ -68,7 +94,7 @@ export interface Config { }; openapi?: { // ; - } + }; // ... } @@ -100,8 +126,8 @@ function resolve_paths(file_path: string, config: Config) { if (Array.isArray(config.rss)) { for (const rss_conf of config.rss) { - if (rss_conf.dir_path) { - rss_conf.dir_path = resolve_path(config.input.root, rss_conf.dir_path); + if (rss_conf.in_dir) { + rss_conf.in_dir = resolve_path(config.input.root, rss_conf.in_dir); rss_conf.out_file = resolve_path(config.output.root, rss_conf.out_file); } } @@ -113,8 +139,8 @@ function resolve_paths(file_path: string, config: Config) { if (Array.isArray(config.calendars)) { for (const cal_conf of config.calendars) { - if (cal_conf.dir_path) { - cal_conf.dir_path = resolve_path(config.input.root, cal_conf.dir_path); + if (cal_conf.in_dir) { + cal_conf.in_dir = resolve_path(config.input.root, cal_conf.in_dir); cal_conf.out_file = resolve_path(config.output.root, cal_conf.out_file); } } @@ -136,3 +162,11 @@ function process_markdown_config(config: any) { } } } + +function resolve_env_var_if_needed(value: T | { from_env: string }) : T | string { + if (typeof value === 'object' && 'from_env' in value) { + return process.env[value.from_env]; + } + + return value; +} diff --git a/src/metadata.ts b/src/metadata.ts index 0dd326c..c19bdbb 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,16 +1,20 @@ export interface Metadata { - last_build: { - time: string; - config_hash: string; - }; + last_build: BuildMetadata; files: { - [file_path: string]: { - first_seen_time: string; - last_build_hash: string; - last_updated_time: string; - }; + [file_path: string]: FileMetadata; }; } +export interface BuildMetadata { + time: string; + config_hash: string; +} + +export interface FileMetadata { + first_seen_time: string; + last_build_hash: string; + last_updated_time: string; +} + // diff --git a/src/template.ts b/src/template.ts index 6c9987f..5fe1f99 100644 --- a/src/template.ts +++ b/src/template.ts @@ -1,5 +1,5 @@ -import { Config } from './conf'; +import { AuthorConfig, CalendarConfig, Config, RSSConfig } from './conf'; import { render as mustache_render } from 'mustache'; import { promises as fs } from 'fs'; import { resolve as resolve_path } from 'path'; @@ -7,15 +7,20 @@ import { glob } from 'glob'; import { load_from_dir } from './fs'; import { ColorTheme } from '@doc-utils/color-themes'; import { ThemeGroups } from './build-files'; +import { ChangeFreq } from './build-files/sitemap'; export interface Context { env?: Record; page?: FrontMatter; base_url: string; page_url: string; + site_title: string; + author: AuthorConfig | AuthorConfig[]; icons: Record; themes: ColorTheme[]; theme_groups: ThemeGroups; + rss_feeds: RSSConfig[]; + calendars: CalendarConfig[]; build_time: { iso: string; rfc2822: string; @@ -26,11 +31,33 @@ export interface Context { } export interface FrontMatter { - title?: string; + skip?: boolean; layout?: string; + title?: string; + description?: string; + tags?: string[]; + author?: string | string[]; + rss?: RSSFrontmatter; + sitemap?: SitemapFrontmatter; + event?: EventFrontmatter; [key: string]: unknown; } +interface SitemapFrontmatter { + change_freq?: ChangeFreq; + priority?: number; +} + +interface EventFrontmatter { + start_time?: string; + end_time?: string; + time_zone?: `${string}/${string}`; +} + +interface RSSFrontmatter { + // +} + export function render_template(template: string, context: Context, layout?: string, partials: Record = { }, tags?: [ string, string ]) { partials['.content'] = template; return mustache_render(layout || template, context, partials, tags); diff --git a/src/time.ts b/src/time.ts new file mode 100644 index 0000000..ca50f45 --- /dev/null +++ b/src/time.ts @@ -0,0 +1,54 @@ + +import { DateTime } from 'luxon'; + +export function now(zone?: string) { + return zone + ? DateTime.now().setZone(zone) + : DateTime.now(); +} + +export function from_iso(time: string, zone?: string) { + return zone + ? DateTime.fromISO(time, { zone }) + : DateTime.fromISO(time); +} + +// function date_formatters(lang: string, time_zone: string) { +// return { +// date() { +// return (text, render) => { +// return format_with_config(render(text), { +// dateStyle: 'short', +// timeZone: time_zone, +// }); +// }; +// }, +// time() { +// return (text, render) => { +// return format_with_config(render(text), { +// timeStyle: 'long', +// timeZone: time_zone, +// }); +// }; +// }, +// datetime() { +// return (text, render) => { +// return format_with_config(render(text), { +// dateStyle: 'short', +// timeStyle: 'long', +// timeZone: time_zone, +// }); +// }; +// }, +// }; + +// function format_with_config(text: string, config: Intl.DateTimeFormatOptions) { +// const date = new Date(text.trim()); +// const formatter = new Intl.DateTimeFormat(lang, config); +// return formatter.format(date); +// } +// } + + + +