From 7297f9cd7a73938ba0305e0ec3853f1b10916170 Mon Sep 17 00:00:00 2001 From: James Brumond Date: Fri, 26 May 2023 16:45:29 -0700 Subject: [PATCH] work on events --- src/build-files/helpers.ts | 100 ++++++++++++++++++++------------ src/build-files/icalendar.ts | 104 +++++++++++++++++++++++++++++----- src/build-files/index.ts | 1 + src/build-files/jsonschema.ts | 10 +--- src/build-files/markdown.ts | 10 +--- src/build-files/mustache.ts | 10 +--- src/build-files/state.ts | 3 +- src/template.ts | 47 +++++++++++---- src/time.ts | 8 +-- 9 files changed, 205 insertions(+), 88 deletions(-) diff --git a/src/build-files/helpers.ts b/src/build-files/helpers.ts index f35695b..e1cf2e5 100644 --- a/src/build-files/helpers.ts +++ b/src/build-files/helpers.ts @@ -5,12 +5,13 @@ import { BuildState } from './state'; 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 { load_partials, FrontMatter, Context, load_layout, render_template, EventFrontmatter } 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'; import { as_context_time, as_html_time, from_iso } from '../time'; +import { FileMetadata } from '../metadata'; export interface OutFileURL { base_url: string; @@ -82,31 +83,42 @@ export async function build_partials(state: BuildState) { Object.assign(state.partials, state.extras); } -export function mustache_context(state: BuildState, page_url: string, frontmatter?: FrontMatter) : Context { - let event: Context['event'] = { - start: null, - end: null, - zone: null, - }; +export function mustache_context(state: BuildState, page_url: string, metadata: FileMetadata, frontmatter?: FrontMatter) : Context { + let event: Context['event']; if (frontmatter?.event) { - const start = from_iso(frontmatter.event.start, frontmatter.event.zone); - event.start = as_context_time(start); - - const end = from_iso(frontmatter.event.end, frontmatter.event.zone); - event.end = as_context_time(end); + event = Array.isArray(frontmatter.event) + ? frontmatter.event.map(to_context_event) + : to_context_event(frontmatter.event); - event.zone = frontmatter.event.zone; + function to_context_event(event_fm: EventFrontmatter) { + const start = from_iso(event_fm.start, event_fm.time_zone); + const end = from_iso(event_fm.end, event_fm.time_zone); + + return { + start: as_context_time(start), + end: as_context_time(end), + time_zone: event_fm.time_zone, + }; + } } + const has_been_updated = metadata.first_seen_time !== metadata.last_updated_time; + return { env: state.env, page: frontmatter, base_url: state.conf.base_url, page_url: page_url, + page_published: as_context_time(from_iso(metadata.first_seen_time), 'dt-published'), + page_updated: has_been_updated ? as_context_time(from_iso(metadata.last_updated_time), 'dt-updated') : null, site_title: state.conf.title, author: get_author(state, frontmatter), event: event, + event_series: Array.isArray(event) && { + start: event[0].start, + end: event[event.length - 1].end, + }, build_time: state.build_time, icons: icons, rss_feeds: state.conf.rss || [ ], @@ -127,7 +139,7 @@ export function mustache_context(state: BuildState, page_url: string, frontmatte }; } -export async function render_page(state: BuildState, in_file: string, 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, hash: string, frontmatter?: any) { if (render_as_markdown) { const opts = Object.assign({ }, state.conf.markdown, { base_url: out_url.abs_url @@ -147,11 +159,21 @@ export async function render_page(state: BuildState, in_file: string, out_file: layout = state.layouts[layout_file]; } + const hash_matches = file_hash_matches(state, in_file, hash); + const rel_in_file = in_file.slice(state.conf.input.root.length); + const old_metadata = state.old_metadata?.files?.[rel_in_file]; + const new_metadata = hash_matches ? structuredClone(old_metadata) : { + first_seen_time: old_metadata?.first_seen_time || state.build_time.iso, + last_build_hash: hash, + last_updated_time: state.build_time.iso, + }; + const tags = state.conf.templates?.tags; - const context = mustache_context(state, out_url.abs_url, frontmatter); + const context = mustache_context(state, out_url.abs_url, new_metadata, frontmatter); const rendered = render_template(text, context, layout, structuredClone(state.partials), tags); await write_text(out_file, rendered); + state.new_metadata.files[rel_in_file] = new_metadata; handle_page_side_effects(state, in_file, out_file, out_url, text, frontmatter); } @@ -230,16 +252,30 @@ function handle_event(state: BuildState, in_file: string, out_url: OutFileURL, f const author_or_authors = get_author(state, frontmatter); const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors; + if (Array.isArray(frontmatter.event)) { + state.event_series.push({ + url: out_url.abs_url, + in_file: in_file, + title: frontmatter.title, + description: frontmatter.description, + author_name: author?.name, + author_email: author?.email, + entries: frontmatter.event.slice(), + }); + + return; + } + state.events.push({ url: out_url.abs_url, in_file: in_file, - title: frontmatter.title, + title: frontmatter.event.title || frontmatter.title, description: frontmatter.description, author_name: author?.name, author_email: author?.email, - start_time: frontmatter.event?.start, - end_time: frontmatter.event?.end, - time_zone: frontmatter.event?.zone, + start: frontmatter.event.start, + end: frontmatter.event.end, + time_zone: frontmatter.event.time_zone, }); } @@ -247,6 +283,12 @@ function handle_calendar(state: BuildState, cal_conf: CalendarConfig, entries: E const author_or_authors = get_author(state, frontmatter); const author = Array.isArray(author_or_authors) ? author_or_authors[0] : author_or_authors; + if (Array.isArray(frontmatter.event)) { + // todo: add each event to the calendar + + return; + } + entries.push({ url: out_url.abs_url, in_file: in_file, @@ -254,9 +296,9 @@ function handle_calendar(state: BuildState, cal_conf: CalendarConfig, entries: E description: frontmatter.description, author_name: author?.name, author_email: author?.email, - start_time: frontmatter.event?.start, - end_time: frontmatter.event?.end, - time_zone: frontmatter.event?.zone, + start: frontmatter.event?.start, + end: frontmatter.event?.end, + time_zone: frontmatter.event?.time_zone, }); } @@ -295,20 +337,6 @@ export function skip_file(state: BuildState, in_file: string, out_file: string, 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) { - in_file = in_file.slice(state.conf.input.root.length); - state.new_metadata.files[in_file] = { - first_seen_time: state.old_metadata?.files?.[in_file]?.first_seen_time || state.build_time.iso, - last_build_hash: hash, - last_updated_time: state.build_time.iso, - }; -} - export function get_author(state: BuildState, frontmatter?: FrontMatter) { if (! frontmatter?.author) { return null; diff --git a/src/build-files/icalendar.ts b/src/build-files/icalendar.ts index 89e630d..26d9ada 100644 --- a/src/build-files/icalendar.ts +++ b/src/build-files/icalendar.ts @@ -2,11 +2,28 @@ import { write_text } from '../fs'; import { BuildState } from './state'; import { map_input_file_to_output_file } from './helpers'; -import { parseICS, CalendarComponent } from 'ical'; +// import { parseICS, CalendarComponent } from 'ical'; import create_calendar, { ICalEventData, ICalCalendarData } from 'ical-generator'; +import { FrontMatterLocation } from '../template'; export type { ICalEventData, ICalCalendarData, ICalAttendeeData, ICalAttendeeStatus } from 'ical-generator'; +export interface EventSeries { + url: string; + in_file: string; + title?: string; + description?: string; + author_name?: string; + author_email?: string; + entries: { + title?: string; + start?: string; + end?: string; + time_zone?: `${string}/${string}`; + location?: FrontMatterLocation; + }[]; +} + export interface EventEntry { url: string; in_file: string; @@ -14,9 +31,10 @@ export interface EventEntry { description?: string; author_name?: string; author_email?: string; - start_time?: string; - end_time?: string; + start?: string; + end?: string; time_zone?: `${string}/${string}`; + location?: FrontMatterLocation; } export async function write_events_and_calendars_if_needed(state: BuildState) { @@ -36,6 +54,39 @@ export async function write_events_and_calendars_if_needed(state: BuildState) { const out_file = await map_input_file_to_output_file(state, entry.in_file, [ '.html', '.md', '.markdown' ], '.ics'); await write_text(out_file, calendar); } + + for (const series of state.event_series) { + const events = series.entries.map((event) => { + return icalendar_event(state, { + url: series.url, + in_file: series.in_file, + title: event.title || series.title, + description: series.description, + author_name: series.author_name, + author_email: series.author_email, + start: event.start, + end: event.end, + time_zone: event.time_zone, + location: event.location, + }); + }); + + const cal_data: ICalCalendarData = { + name: series.title, + description: series.description, + url: series.url, + prodId: { + company: 'jbrumond.me', + product: 'docs2website', + language: 'EN', + }, + // ... + }; + + const calendar = create_icalendar(cal_data, events); + const out_file = await map_input_file_to_output_file(state, series.in_file, [ '.html', '.md', '.markdown' ], '.ics'); + await write_text(out_file, calendar); + } } if (state.conf.calendars) { @@ -62,16 +113,16 @@ export async function write_events_and_calendars_if_needed(state: BuildState) { } } -export function parse_icalendar(contents: string) { - const parsed = parseICS(contents); - const calendar: CalendarComponent[] = [ ]; +// export function parse_icalendar(contents: string) { +// const parsed = parseICS(contents); +// const calendar: CalendarComponent[] = [ ]; - for (const data of Object.values(parsed)) { - calendar.push(data); - } +// for (const data of Object.values(parsed)) { +// calendar.push(data); +// } - return calendar; -} +// return calendar; +// } export function create_icalendar(cal: ICalCalendarData, events: ICalEventData | ICalEventData[]) { const calendar = create_calendar(cal); @@ -97,8 +148,8 @@ export function icalendar_event(state: BuildState, entry: EventEntry) : ICalEven id: entry.url, summary: entry.title, description: entry.description || void 0, - start: entry.start_time, - end: entry.end_time, + start: entry.start, + end: entry.end, url: entry.url, timezone: entry.time_zone, created: metadata.first_seen_time, @@ -107,6 +158,7 @@ export function icalendar_event(state: BuildState, entry: EventEntry) : ICalEven name: entry.author_name || 'Unknown', email: entry.author_email, }, + location: format_location(entry.location), // attendees: post.mentions.flatMap((mention) : ICalAttendeeData | ICalAttendeeData[] => { // if (mention.is_rsvp && mention.is_reply_to_this) { // const ext = mention.external as ExternalEntry; @@ -129,3 +181,29 @@ export function icalendar_event(state: BuildState, entry: EventEntry) : ICalEven // }), }; } + +function format_location(loc: FrontMatterLocation) { + if (! loc) { + return; + } + + if (loc.description) { + if (loc.address) { + return `${loc.description} (${loc.address})`; + } + + if (loc.lat && loc.long) { + return `${loc.description} [${loc.lat}, ${loc.long}]`; + } + + return loc.description; + } + + if (loc.address) { + return loc.address; + } + + if (loc.lat && loc.long) { + return `[${loc.lat}, ${loc.long}]`; + } +} diff --git a/src/build-files/index.ts b/src/build-files/index.ts index b44d1c4..1806e60 100644 --- a/src/build-files/index.ts +++ b/src/build-files/index.ts @@ -61,6 +61,7 @@ export async function build_docs_project(conf: Config) { rss: [ ], sitemap: [ ], events: [ ], + event_series: [ ], calendars: [ ], build_time: as_context_time(now), }; diff --git a/src/build-files/jsonschema.ts b/src/build-files/jsonschema.ts index a4ed44c..813f9df 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, copy_metadata, 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, map_input_file_to_output_file, map_output_file_to_url, render_page } from './helpers'; export async function render_json_schema_files(state: BuildState) { const promises: Promise[] = [ ]; @@ -124,14 +124,8 @@ export async function render_json_schema(state: BuildState, schema: unknown, in_ } promises.push( - render_page(state, in_file, out_file, out_url, markdown, true, frontmatter) + render_page(state, in_file, out_file, out_url, markdown, true, hash, 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 ee0a1c0..3c6f795 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, copy_metadata, 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, map_input_file_to_output_file, map_output_file_to_url, render_page } from './helpers'; export async function render_markdown_files(state: BuildState) { const promises: Promise[] = [ ]; @@ -39,11 +39,5 @@ export async function render_markdown_file(state: BuildState, in_file: string) { return; } - await render_page(state, in_file, out_file, out_url, text, true, frontmatter); - - if (file_hash_matches(state, in_file, hash)) { - return copy_metadata(state, in_file); - } - - update_metadata(state, in_file, hash); + await render_page(state, in_file, out_file, out_url, text, true, hash, frontmatter); } diff --git a/src/build-files/mustache.ts b/src/build-files/mustache.ts index 51e028e..9c27a10 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, copy_metadata, 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, map_input_file_to_output_file, map_output_file_to_url, render_page } from './helpers'; export async function render_text_file_templates(state: BuildState) { const promises: Promise[] = [ ]; @@ -39,11 +39,5 @@ 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 copy_metadata(state, in_file); - } - - update_metadata(state, in_file, hash); + await render_page(state, in_file, out_file, out_url, text, false, hash, frontmatter); } diff --git a/src/build-files/state.ts b/src/build-files/state.ts index 576a335..8ace240 100644 --- a/src/build-files/state.ts +++ b/src/build-files/state.ts @@ -4,7 +4,7 @@ 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'; +import { EventEntry, EventSeries } from './icalendar'; import { ContextTime } from '../template'; export interface BuildState { @@ -22,6 +22,7 @@ export interface BuildState { rss: RSSEntry[][]; sitemap: SitemapEntry[]; events: EventEntry[]; + event_series: EventSeries[]; calendars: EventEntry[][]; build_time: ContextTime; } diff --git a/src/template.ts b/src/template.ts index c95ed97..da0f96d 100644 --- a/src/template.ts +++ b/src/template.ts @@ -8,19 +8,20 @@ import { load_from_dir } from './fs'; import { ColorTheme } from '@doc-utils/color-themes'; import { ThemeGroups } from './build-files'; import { ChangeFreq } from './build-files/sitemap'; -import { DateTime } from 'luxon'; export interface Context { env?: Record; page?: FrontMatter; base_url: string; page_url: string; + page_published: ContextTime; + page_updated: ContextTime; site_title: string; author: AuthorConfig | AuthorConfig[]; - event?: { - start: ContextTime; - end: ContextTime; - zone: `${string}/${string}`; + event?: ContextEvent | ContextEvent[]; + event_series?: { + start?: ContextTime; + end?: ContextTime; }; icons: Record; themes: ColorTheme[]; @@ -39,6 +40,22 @@ export interface ContextTime { html: string; } +export interface ContextEvent { + title?: string; + start?: ContextTime; + end?: ContextTime; + time_zone?: `${string}/${string}`; + location?: ContextLocation; +} + +export interface ContextLocation { + description?: string; + lat?: string; + long?: string; + // todo: represent this better? + address?: string; +} + export interface FrontMatter { skip?: boolean; layout?: string; @@ -48,23 +65,33 @@ export interface FrontMatter { author?: string | string[]; rss?: RSSFrontmatter; sitemap?: SitemapFrontmatter; - event?: EventFrontmatter; + event?: EventFrontmatter | EventFrontmatter[]; [key: string]: unknown; } -interface SitemapFrontmatter { +export interface SitemapFrontmatter { skip?: boolean; change_freq?: ChangeFreq; priority?: number; } -interface EventFrontmatter { +export interface EventFrontmatter { + title?: string; start?: string; end?: string; - zone?: `${string}/${string}`; + time_zone?: `${string}/${string}`; + location?: FrontMatterLocation; } -interface RSSFrontmatter { +export interface FrontMatterLocation { + description?: string; + lat?: string; + long?: string; + // todo: represent this better? + address?: string; +} + +export interface RSSFrontmatter { skip?: boolean; } diff --git a/src/time.ts b/src/time.ts index 4b425a0..d9d7638 100644 --- a/src/time.ts +++ b/src/time.ts @@ -13,20 +13,20 @@ export function from_iso(time: string, zone?: string) { : DateTime.fromISO(time); } -export function as_html_time(time: DateTime, lang?: string, config?: Intl.DateTimeFormatOptions) { +export function as_html_time(time: DateTime, classname = '', lang?: string, config?: Intl.DateTimeFormatOptions) { if (lang && config) { const formatter = new Intl.DateTimeFormat(lang, config); const formatted = formatter.format(new Date(time.toISO())); return ``; } - return ``; + return ``; } -export function as_context_time(time: DateTime, lang?: string, config?: Intl.DateTimeFormatOptions) { +export function as_context_time(time: DateTime, classname = '', lang?: string, config?: Intl.DateTimeFormatOptions) { return { iso: time.toISO(), rfc2822: time.toRFC2822(), - html: as_html_time(time, lang, config), + html: as_html_time(time, classname, lang, config), }; }