wiki-formant 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -728,7 +728,7 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
728
728
 
729
729
  The dispatch over a repo's own block union stays in that repo; the bodies come from here. `coreAtomicText` has a prose body for every core leaf — the stats, link-grid and page-list bodies joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
730
730
 
731
- The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `testimonial`, `events`, `recentPages`, `pageList`, and `list`, which does what the two page lists do between them — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
731
+ The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `testimonial`, `events`, `locations`, `recentPages`, `pageList`, and `list`, which does what the two page lists do between them — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
732
732
 
733
733
  ```ts
734
734
  import { coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
@@ -782,7 +782,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
782
782
 
783
783
  ## Sanitising stored HTML
784
784
 
785
- `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render three HTML fields themselves — `linkGrid` descriptions, and `references` and `events` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
785
+ `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render four HTML fields themselves — `linkGrid` descriptions, `references` and `events` text, and a place's note — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
786
786
 
787
787
  ```ts
788
788
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
@@ -883,7 +883,7 @@ const SCHEMA: BlockSchema<Block> = {
883
883
  <BlockEditor blocks={blocks} onChange={setBlocks} schema={SCHEMA} kit={KIT} />
884
884
  ```
885
885
 
886
- The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `TestimonialFields`, `EventsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
886
+ The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `TestimonialFields`, `EventsFields`, `LocationsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
887
887
 
888
888
  An editor that draws each block in place, with a list of its own (miow's), hosts the same fields in `<BlockFieldsKit kit={KIT}>`, without `<BlockEditor>`. Only the two container fields need the full editor, since they hold a block list.
889
889
 
@@ -891,9 +891,11 @@ A `testimonial` is `{ quote, author, role?, avatarUrl? }`, drawn by `Testimonial
891
891
 
892
892
  An `events` block is `{ upcoming?, past?, items: [{ id, date, end?, text }] }`: exhibitions, shows, talks, each dated as precisely as it is known (`EVENT_DATE`: `2026`, `2026-11` or `2026-11-12`), its end no earlier than its start, and its `text` inline HTML saying what and where. The items are stored in any order. `EventsView` sets those to come or on now soonest first under `upcoming` ("Upcoming"), with "Now on" beside one that has begun, and those that have ended latest first under `past` ("Past"), as `section.events` holding an `h2` and an `ol.event-list` for each; `base.css` gives every row's dates one column, shared by both lists. A date compares at its own precision, so `2026-08` is on until August ends. The page is split twice: by `day`, the day it was rendered (`today()` on the server), and again in the browser by the visitor's own day, because a cached page can be a day old and a show that closed since then is past. A wiki whose renderer is a client component fills `day` on the server when the page is read (it is never stored), so hydration matches what the server drew. `splitEvents`, `eventWhen` ("12 – 18 Nov 2026"), `eventBegun`, `eventEnded` and `eventEndsBefore` are the same rules for a twin, a feed or an editor; `coreAtomicText` and `coreAtomicToMarkdown` split by `day` too. `EventsFields` edits the rows in the order they were written, each with how its dates read and where the page sets it today, and `sanitizeCoreLeaf` cleans each event's text. miow had it first.
893
893
 
894
+ A `locations` block is `{ heading?, map?, items: [{ id, name?, image?, address?, map?, phone?, email?, hours?, timeZone?, note? }] }`: where a business is and when it is open, each place with its address or its hours. `hours` are rows of `{ days, opens, closes }`, days `mon` to `sun` and times 24-hour; a closing time at or before the opening one is the next morning, and one equal to it is open all day. `LocationsView` draws `section.locations` holding `ul.places`, as many to a row as fit: each place's picture, name (an `h2`, or an `h3` under `heading`), address linked to its map (`mapHref`: the link it was given, or a search for its words), its week as grouped rows ("Mon–Fri 8am–6pm", "Sun Closed"), its note, phone and email, and with `map` an embedded map (`mapEmbed`, from a host on `DEFAULT_IFRAME_HOSTS`). Whether it is open and which row is today depend on the moment it is read, so the server draws the hours alone and the browser adds `.place-state` ("Open · closes 6pm", "Closed · opens Mon 8am") and the row's `aria-current`, by the place's own clock (`timeZone`, `DEFAULT_ZONE` `Europe/London` unset), once a minute. `wiki-formant/hours` holds those rules — `hoursRows`, `openState`, `clock`, `hoursLd` (schema.org's `OpeningHoursSpecification`), `DAYS`, `TIME`, `isZone` — for a twin, a feed or structured data. `LocationsFields` edits the places, their hours day by day; `sanitizeCoreLeaf` cleans each note. A site whose places are its own business states each as a `LocalBusiness` with `hoursLd`; a wiki describing places does not, since they are not the wiki's own. miow had it first.
895
+
894
896
  A banner with no text of its own shows `BANNER_MESSAGES` from `wiki-formant/text`, a sentence per variant that names no wiki; `BannerView` takes it unless `message` overrides it.
895
897
 
896
- The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `TestimonialBlock`, `EventsBlock`, `ListBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. `CoreLeafBlock` is every one of them but the list and the containers, as one union, and `CoreLeafType` its types. A wiki builds its atomic union from it, `CoreLeafBlock | ListBlock | its native types`, with its own `list` holding its own page rows, and derives its `BlockType` from the result. A leaf added here then reaches every wiki with the release, and fails to compile there until its label, its view and its fields are wired: a `Record` keyed by the block types is missing a key, and a switch closed with `block satisfies never` has a case left over. miow, whose union is zod's, asserts that it carries every `CoreLeafType`.
898
+ The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `TestimonialBlock`, `EventsBlock`, `LocationsBlock`, `ListBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. `CoreLeafBlock` is every one of them but the list and the containers, as one union, and `CoreLeafType` its types. A wiki builds its atomic union from it, `CoreLeafBlock | ListBlock | its native types`, with its own `list` holding its own page rows, and derives its `BlockType` from the result. A leaf added here then reaches every wiki with the release, and fails to compile there until its label, its view and its fields are wired: a `Record` keyed by the block types is missing a key, and a switch closed with `block satisfies never` has a case left over. miow, whose union is zod's, asserts that it carries every `CoreLeafType`.
897
899
 
898
900
  `ListBlock` is one list of the wiki's pages where those two were two: the pages in a section (`source: 'under'`), the newest, in a section or across the wiki (`'newest'`, `count`), or pages picked by hand (`'chosen'`, `pages`, keyed as the wiki keys a page, an id or a path), shown as links with their age or as cards (`show`). A wiki resolves it to `items` as it does a page list. `ListFields` edits it, with `pages` to pick from and `sections` to name; a wiki with a source of its own, a shop's products say, passes it in `sources` and draws its fields as `children`.
899
901
 
@@ -1103,7 +1105,7 @@ the site's stylesheet gives them and the box a look.
1103
1105
 
1104
1106
  ## Base stylesheet
1105
1107
 
1106
- `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the stats block's grid, the events block's shared date column, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity. The one set of tokens it declares is the page frame every site lays out by: `--content-width` (72rem, gutters inside), `--sidebar-width` (18rem) and `--page-pad` (16 / 24 / 32px at base / 640 / 1024). They sit in the components layer, so a site never restates them in `@theme`, which would lose; an element that must differ sets its own.
1108
+ `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the stats block's grid, the events block's shared date column, the locations block's row of places, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity. The one set of tokens it declares is the page frame every site lays out by: `--content-width` (72rem, gutters inside), `--sidebar-width` (18rem) and `--page-pad` (16 / 24 / 32px at base / 640 / 1024). They sit in the components layer, so a site never restates them in `@theme`, which would lose; an element that must differ sets its own.
1107
1109
 
1108
1110
  ```css
1109
1111
  @import "wiki-formant/base.css" layer(components);
package/dist/base.css CHANGED
@@ -159,7 +159,8 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
159
159
  sets its photo, round, beside who said it; an events
160
160
  block sets every row's dates in one column, shared by Upcoming and Past
161
161
  through a subgrid, and stacks them over the words wherever it is narrower
162
- than 30rem: a phone, or a narrow column. */
162
+ than 30rem: a phone, or a narrow column; a locations block sets as many
163
+ places to a row as fit, each its picture, address, hours and map stacked. */
163
164
  .stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
164
165
  @media (min-width: 40rem) {
165
166
  .stat-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@@ -186,6 +187,16 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
186
187
  @container (max-width: 30rem) {
187
188
  :is(.event-list, .event-list li, .event-list time) { display: block; }
188
189
  }
190
+ .places { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); margin: 0; padding: 0; list-style: none; }
191
+ .place { display: grid; align-content: start; }
192
+ .place > :is(p, address) { margin: 0; }
193
+ .place address { font-style: normal; white-space: pre-line; }
194
+ .place-picture { display: block; width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
195
+ .place-hours { width: max-content; max-width: 100%; border-collapse: collapse; }
196
+ .place-hours :is(th, td) { border: 0; background: none; text-align: left; vertical-align: top; }
197
+ .place-hours th { white-space: nowrap; }
198
+ .place-contact { display: flex; flex-wrap: wrap; }
199
+ .place-map { width: 100%; aspect-ratio: 4 / 3; max-height: 22rem; border: 0; }
189
200
  .heading-anchor,
190
201
  .ref-backlink,
191
202
  .reference-link { text-decoration: none; }
@@ -326,6 +337,22 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
326
337
  }
327
338
  .event-now { margin-left: 0.5em; padding: 0.1em 0.55em; border-radius: 999px; background: var(--ui-accent); color: var(--ui-on-accent); font-size: 0.8em; white-space: nowrap; }
328
339
 
340
+ .locations { margin-block: 1rem; }
341
+ .locations > h2 { margin: 0 0 1rem; }
342
+ .place :is(h2, h3) { margin: 0 0 0.5rem; }
343
+ .places { gap: 2rem 1.5rem; }
344
+ .place { gap: 0.5rem; }
345
+ .place-picture, .place-map { border-radius: var(--ui-radius); }
346
+ .place-map { margin-top: 0.5rem; background: var(--ui-surface); }
347
+ .place-state { font-size: 0.9em; color: var(--ui-muted); }
348
+ .place-state[data-open] { font-weight: 600; color: inherit; }
349
+ .place-state[data-open]::before { content: ''; display: inline-block; width: 0.5em; height: 0.5em; margin-right: 0.45em; border-radius: 50%; background: var(--ui-up); vertical-align: 0.1em; }
350
+ .place-hours { font-variant-numeric: tabular-nums; }
351
+ .place-hours :is(th, td) { padding: 0.15em 0; }
352
+ .place-hours th { padding-right: 1.25em; font-weight: 400; color: var(--ui-muted); }
353
+ .place-hours tr[aria-current] :is(th, td) { font-weight: 600; color: inherit; }
354
+ .place-contact { gap: 0.25em 1em; }
355
+
329
356
  .link-grid { display: flex; flex-direction: column; gap: 1rem; margin-block: 1rem; }
330
357
  .link-grid-group-description { margin: 0 0 0.75rem; font-size: var(--ui-small); color: var(--ui-muted); }
331
358
  .link-grid-group-description p { margin: 0; }
@@ -1,5 +1,5 @@
1
1
  import { type ComponentType, type ReactElement, type ReactNode } from 'react';
2
- import { type BannerBlock, type CodeTabsBlock, type ColumnsBlock, type EventsBlock, type InfoboxBlock, type LinkGridBlock, type ReferencesBlock, type StatsBlock, type TestimonialBlock } from './blocks.js';
2
+ import { type BannerBlock, type CodeTabsBlock, type ColumnsBlock, type EventsBlock, type LocationsBlock, type InfoboxBlock, type LinkGridBlock, type ReferencesBlock, type StatsBlock, type TestimonialBlock } from './blocks.js';
3
3
  export interface BlockEditorClassNames {
4
4
  /** A page's block list. */
5
5
  list?: string;
@@ -182,6 +182,16 @@ export declare function EventsFields({ block, onUpdate, hint }: {
182
182
  onUpdate: (block: EventsBlock) => void;
183
183
  hint?: string;
184
184
  }): import("react").JSX.Element;
185
+ /**
186
+ * Places, each with its address or its hours: its name where there are
187
+ * several, a picture, the address a line to a line, a map link, its hours day
188
+ * by day, its clock, a note and how to reach it. The page says whether each is
189
+ * open by its own clock.
190
+ */
191
+ export declare function LocationsFields({ block, onUpdate }: {
192
+ block: LocationsBlock;
193
+ onUpdate: (block: LocationsBlock) => void;
194
+ }): import("react").JSX.Element;
185
195
  /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
186
196
  export declare function ReferencesFields({ block, onUpdate, title, noun, hint, }: {
187
197
  block: ReferencesBlock;
@@ -25,6 +25,7 @@ import { createContext, memo, useCallback, useContext, useMemo, useState, } from
25
25
  import { BlockActions, useBlockOperations } from './react.js';
26
26
  import { CODE_LANGS, DEFAULT_LANG, EVENT_DATE, eventBegun, eventEnded, eventWhen, LIST_SOURCES, today, } from './blocks.js';
27
27
  import { BANNER_VARIANTS } from './text.js';
28
+ import { DAY_NAMES, DAYS, DEFAULT_ZONE, hoursRows, TIME } from './hours.js';
28
29
  import { MAX_BLOCK_ROWS } from './validation.js';
29
30
  import { cx } from './html.js';
30
31
  const Context = createContext(null);
@@ -183,6 +184,27 @@ export function EventsFields({ block, onUpdate, hint }) {
183
184
  return (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Starts", placeholder: "Starts, e.g. 2026-11", value: e.date, onChange: date => set(replaceAt(items, i, { ...e, date: date ?? '' })) }), _jsx(TextInput, { label: "Ends (optional)", optional: true, value: e.end, onChange: end => set(replaceAt(items, i, { ...e, end })) }), _jsx(Remove, { label: `Remove event ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }), _jsx(TextInput, { label: "What it is and where", placeholder: "What it is and where, e.g. Group show, Park Royal Gallery, London", value: e.text, onChange: text => set(replaceAt(items, i, { ...e, text: text ?? '' })) }), _jsx("small", { className: c.muted, children: dated ? `${eventWhen(e)} · ${eventEnded(e, day) ? 'past' : eventBegun(e, day) ? 'now on' : 'upcoming'}` : 'A year, month or day: 2026, 2026-11 or 2026-11-12' })] }, e.id));
184
185
  }), _jsx(Add, { onPress: () => set([...items, { id: newId(), date: day.slice(0, 7), text: '' }]), children: "Add event" }), _jsx("small", { className: c.muted, children: hint ?? 'Readers see those to come soonest first and those that have ended latest first, each moving on its own once it ends.' })] }));
185
186
  }
187
+ /** A place's hours, a row for each set of days that keeps the same times, with how the week will read under them. */
188
+ function HoursFields({ hours, onChange }) {
189
+ const c = useEditorContext().kit.classNames;
190
+ const valid = hours.filter(h => h.days.length && TIME.test(h.opens) && TIME.test(h.closes));
191
+ return (_jsxs("div", { className: c.stack, children: [hours.map((h, i) => (_jsxs("div", { className: c.row, children: [_jsx("span", { role: "group", "aria-label": "Days", children: DAYS.map((d, j) => (_jsxs("label", { title: DAY_NAMES[j], children: [_jsx("input", { type: "checkbox", "aria-label": DAY_NAMES[j], checked: h.days.includes(d), onChange: () => onChange(replaceAt(hours, i, { ...h, days: DAYS.filter(x => (x === d ? !h.days.includes(d) : h.days.includes(x))) })) }), DAY_NAMES[j].slice(0, 3)] }, d))) }), _jsx("input", { type: "time", "aria-label": "Opens", className: c.input, value: h.opens, onChange: e => onChange(replaceAt(hours, i, { ...h, opens: e.target.value })) }), _jsx("input", { type: "time", "aria-label": "Closes", className: c.input, value: h.closes, onChange: e => onChange(replaceAt(hours, i, { ...h, closes: e.target.value })) }), _jsx(Remove, { label: `Remove hours ${i + 1}`, onPress: () => onChange(removeAt(hours, i)) })] }, i))), _jsx(Add, { onPress: () => onChange([...hours, { days: hours.length ? [] : ['mon', 'tue', 'wed', 'thu', 'fri'], opens: '09:00', closes: '17:00' }]), children: "Add opening hours" }), valid.length > 0 && _jsx("small", { className: c.muted, children: hoursRows(valid).map(r => `${r.label} ${r.times}`).join(' · ') })] }));
192
+ }
193
+ /**
194
+ * Places, each with its address or its hours: its name where there are
195
+ * several, a picture, the address a line to a line, a map link, its hours day
196
+ * by day, its clock, a note and how to reach it. The page says whether each is
197
+ * open by its own clock.
198
+ */
199
+ export function LocationsFields({ block, onUpdate }) {
200
+ const c = useEditorContext().kit.classNames;
201
+ const items = block.items;
202
+ const set = (next) => onUpdate({ ...block, items: next });
203
+ return (_jsxs("div", { className: c.stack, children: [_jsx(TextInput, { label: "Heading over the places (optional)", placeholder: "Our shops", optional: true, value: block.heading, onChange: heading => onUpdate({ ...block, heading }) }), items.map((p, i) => {
204
+ const setPlace = (patch) => set(replaceAt(items, i, { ...p, ...patch }));
205
+ return (_jsxs("div", { className: cx(c.nest, c.stack), children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Which one (optional)", placeholder: "Covent Garden", optional: true, value: p.name, onChange: name => setPlace({ name }) }), _jsx(Remove, { label: `Remove place ${p.name || i + 1}`, onPress: () => set(removeAt(items, i)) })] }), _jsx(Field, { label: "Address, a line to a line", children: _jsx("textarea", { "aria-label": "Address", rows: 3, className: c.input, placeholder: '27 Monmouth Street\nLondon WC2H 9EU', value: p.address ?? '', onChange: e => setPlace({ address: e.target.value.trim() ? e.target.value : undefined }) }) }), _jsx(TextInput, { label: "Map link (optional)", type: "url", placeholder: "Unset, a search for the address", optional: true, value: p.map, onChange: map => setPlace({ map }) }), _jsx(HoursFields, { hours: p.hours ?? [], onChange: hours => setPlace({ hours: hours.length ? hours : undefined }) }), !!p.hours?.length && _jsx(TextInput, { label: "Time zone", placeholder: DEFAULT_ZONE, optional: true, value: p.timeZone, onChange: timeZone => setPlace({ timeZone }) }), _jsx(TextInput, { label: "What the hours do not say (optional)", placeholder: "Closed on bank holidays", optional: true, value: p.note, onChange: note => setPlace({ note }) }), _jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Phone (optional)", placeholder: "020 7232 3010", optional: true, value: p.phone, onChange: phone => setPlace({ phone }) }), _jsx(TextInput, { label: "Email (optional)", placeholder: "hello@example.com", optional: true, value: p.email, onChange: email => setPlace({ email }) })] }), _jsx(TextInput, { label: "Picture address (optional)", type: "url", placeholder: "https://", optional: true, value: p.image, onChange: image => setPlace({ image }) })] }, p.id));
206
+ }), _jsx(Add, { onPress: () => set([...items, { id: newId(), hours: [{ days: ['mon', 'tue', 'wed', 'thu', 'fri'], opens: '09:00', closes: '17:00' }] }]), children: "Add place" }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: !!block.map, onChange: e => onUpdate({ ...block, map: e.target.checked || undefined }) }), " Show a map of each address"] }), _jsx("small", { className: c.muted, children: "Readers see the days grouped, today's marked, and whether each place is open now by its own clock." })] }));
207
+ }
186
208
  /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
187
209
  export function ReferencesFields({ block, onUpdate, title = 'References', noun = 'reference', hint, }) {
188
210
  const c = useEditorContext().kit.classNames;
@@ -1,5 +1,6 @@
1
1
  import { type ImgHTMLAttributes, type ReactNode } from 'react';
2
- import type { CodeTab, EventItem, LinkGridGroup, ReferenceItem, ResolvedPageRef, StatItem } from './blocks.js';
2
+ import type { CodeTab, EventItem, LinkGridGroup, PlaceItem, ReferenceItem, ResolvedPageRef, StatItem } from './blocks.js';
3
+ import { type Hours } from './hours.js';
3
4
  import type { WikiLinkComponent } from './react-server.js';
4
5
  export interface CodeTabsViewProps {
5
6
  tabs: readonly CodeTab[];
@@ -148,6 +149,40 @@ export interface EventsViewProps {
148
149
  * reaches here through `sanitizeCoreLeaf`, as a reference's does.
149
150
  */
150
151
  export declare function EventsView({ items, upcoming, past, day }: EventsViewProps): import("react").JSX.Element;
152
+ /**
153
+ * A place's week of hours, with whether it is open and today's row marked by
154
+ * its own clock. A cached page cannot know the moment it is read, so the
155
+ * server draws the hours alone and the visitor's browser says the rest.
156
+ */
157
+ export declare function PlaceHours({ hours, timeZone }: {
158
+ hours: readonly Hours[];
159
+ timeZone?: string;
160
+ }): import("react").JSX.Element;
161
+ export interface LocationsViewProps {
162
+ /** An h2 over the places, whose names are then h3s; unset, each name is an h2. */
163
+ heading?: string | null;
164
+ /** A map of each address under it. */
165
+ map?: boolean | null;
166
+ /**
167
+ * The places. `photo` is a picture's sized `<img>` attributes, for a site
168
+ * with an image pipeline: data, not a function, since a server component
169
+ * renders this client view.
170
+ */
171
+ items: readonly (PlaceItem & {
172
+ id?: string;
173
+ photo?: Pick<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'sizes'>;
174
+ })[];
175
+ }
176
+ /**
177
+ * Where a business is and when it is open, as `section.locations` holding
178
+ * `ul.places`, a `li.place` for each: its picture (`.place-picture`), its name,
179
+ * its `address` (linked to its map), whether it is open now
180
+ * (`.place-state[data-open]`) over its week of hours (`table.place-hours`,
181
+ * today's row `aria-current`), its note, its phone and email
182
+ * (`.place-contact`) and, with `map`, an embedded map (`.place-map`). The note
183
+ * is written as HTML, so it reaches here through `sanitizeCoreLeaf`.
184
+ */
185
+ export declare function LocationsView({ heading, map, items }: LocationsViewProps): import("react").JSX.Element;
151
186
  /**
152
187
  * A maintenance notice.
153
188
  *
@@ -19,6 +19,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
19
19
  // HTML processor, the router's link — arrives as a prop.
20
20
  import { Fragment, useState, useSyncExternalStore } from 'react';
21
21
  import { eventBegun, eventWhen, splitEvents, today } from './blocks.js';
22
+ import { clock, DEFAULT_ZONE, hoursRows, mapEmbed, mapHref, openState } from './hours.js';
22
23
  import { Anchor } from './react-server.js';
23
24
  import { safeLinkHref } from './validation.js';
24
25
  import { cx } from './html.js';
@@ -141,6 +142,39 @@ export function EventsView({ items, upcoming, past, day }) {
141
142
  [past || 'Past', split.past],
142
143
  ].map(([heading, rows], ended) => rows.length ? (_jsxs(Fragment, { children: [_jsx("h2", { children: heading }), _jsx("ol", { className: "event-list", children: rows.map((e, i) => (_jsxs("li", { children: [_jsx("time", { dateTime: e.date, children: eventWhen(e) }), _jsxs("span", { children: [_jsx("span", { dangerouslySetInnerHTML: { __html: e.text } }), !ended && eventBegun(e, now) && _jsx("small", { className: "event-now", children: "Now on" })] })] }, e.id ?? i))) })] }, ended)) : null) }));
143
144
  }
145
+ // ---- locations --------------------------------------------------------------
146
+ const everyMinute = (tick) => {
147
+ const timer = setInterval(tick, 60_000);
148
+ return () => clearInterval(timer);
149
+ };
150
+ /**
151
+ * A place's week of hours, with whether it is open and today's row marked by
152
+ * its own clock. A cached page cannot know the moment it is read, so the
153
+ * server draws the hours alone and the visitor's browser says the rest.
154
+ */
155
+ export function PlaceHours({ hours, timeZone = DEFAULT_ZONE }) {
156
+ const now = useSyncExternalStore(everyMinute, () => clock(new Date(), timeZone).minute, () => null);
157
+ const state = now === null ? null : openState([...hours], now);
158
+ const day = now === null ? null : clock(new Date(), timeZone).day;
159
+ return (_jsxs(_Fragment, { children: [state && (_jsx("p", { className: "place-state", "data-open": state.open || undefined, children: state.words })), _jsx("table", { className: "place-hours", children: _jsx("tbody", { children: hoursRows([...hours]).map(r => (_jsxs("tr", { "aria-current": day && r.days.includes(day) ? 'date' : undefined, children: [_jsx("th", { scope: "row", children: r.label }), _jsx("td", { children: r.times })] }, r.label))) }) })] }));
160
+ }
161
+ /**
162
+ * Where a business is and when it is open, as `section.locations` holding
163
+ * `ul.places`, a `li.place` for each: its picture (`.place-picture`), its name,
164
+ * its `address` (linked to its map), whether it is open now
165
+ * (`.place-state[data-open]`) over its week of hours (`table.place-hours`,
166
+ * today's row `aria-current`), its note, its phone and email
167
+ * (`.place-contact`) and, with `map`, an embedded map (`.place-map`). The note
168
+ * is written as HTML, so it reaches here through `sanitizeCoreLeaf`.
169
+ */
170
+ export function LocationsView({ heading, map, items }) {
171
+ const Name = heading ? 'h3' : 'h2';
172
+ return (_jsxs("section", { className: "locations", children: [heading && _jsx("h2", { children: heading }), _jsx("ul", { className: "places", children: items.map((p, i) => {
173
+ const link = safeLinkHref(mapHref(p));
174
+ const image = p.image && safeLinkHref(p.image);
175
+ return (_jsxs("li", { className: "place", children: [image && _jsx("img", { src: image, ...p.photo, alt: "", className: "place-picture", loading: "lazy" }), p.name && _jsx(Name, { children: p.name }), p.address && _jsx("address", { children: link ? _jsx("a", { href: link, children: p.address.trim() }) : p.address.trim() }), !!p.hours?.length && _jsx(PlaceHours, { hours: p.hours, timeZone: p.timeZone }), p.note && _jsx("p", { dangerouslySetInnerHTML: { __html: p.note } }), (p.phone || p.email) && (_jsxs("p", { className: "place-contact", children: [p.phone && _jsx("a", { href: `tel:${p.phone.replace(/[^\d+]/g, '')}`, children: p.phone }), p.email && _jsx("a", { href: `mailto:${p.email}`, children: p.email })] })), map && p.address && (_jsx("iframe", { className: "place-map", src: mapEmbed({ address: p.address, map: p.map }), title: `Map of ${p.name || p.address.split('\n')[0]}`, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade" }))] }, p.id ?? i));
176
+ }) })] }));
177
+ }
144
178
  // ---- banner -----------------------------------------------------------------
145
179
  /**
146
180
  * A maintenance notice.
package/dist/blocks.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type RelativeTimeStyle } from './freshness.js';
2
+ import { type Hours } from './hours.js';
2
3
  import type { BlockGroup } from './revisions.js';
3
4
  import { type BannerVariant } from './text.js';
4
5
  /**
@@ -52,6 +53,24 @@ export interface EventItem {
52
53
  end?: string | null;
53
54
  text: string;
54
55
  }
56
+ /**
57
+ * One place a business is: which one (`name`, where it has several), a
58
+ * picture, its address a line to a line, a map link, a phone, an email, its
59
+ * regular hours (`wiki-formant/hours`), the IANA zone its clock is in, and a
60
+ * line of inline HTML for what the hours do not say. It has its address or its
61
+ * hours, or both.
62
+ */
63
+ export interface PlaceItem {
64
+ name?: string;
65
+ image?: string;
66
+ address?: string;
67
+ map?: string;
68
+ phone?: string;
69
+ email?: string;
70
+ hours?: Hours[];
71
+ timeZone?: string;
72
+ note?: string;
73
+ }
55
74
  export interface LinkGridLink {
56
75
  label: string;
57
76
  href: string;
@@ -80,6 +99,9 @@ export type EditableEventItem = EventItem & {
80
99
  id: string;
81
100
  end?: string;
82
101
  };
102
+ export type EditablePlaceItem = PlaceItem & {
103
+ id: string;
104
+ };
83
105
  export interface ContentBlock {
84
106
  id: string;
85
107
  type: 'content';
@@ -132,6 +154,14 @@ export interface EventsBlock {
132
154
  items: EditableEventItem[];
133
155
  day?: string;
134
156
  }
157
+ /** Where a business is and when it is open: `heading` an h2 over the places, whose names are then h3s, and `map` a map under each address. */
158
+ export interface LocationsBlock {
159
+ id: string;
160
+ type: 'locations';
161
+ heading?: string;
162
+ map?: true;
163
+ items: EditablePlaceItem[];
164
+ }
135
165
  /** A Wikipedia-style notice. `text` replaces the variant's standard message. */
136
166
  export interface BannerBlock {
137
167
  id: string;
@@ -147,7 +177,7 @@ export interface BannerBlock {
147
177
  * there until it is drawn, edited and labelled. The list is left out because
148
178
  * each repo resolves its `items` to its own rows.
149
179
  */
150
- export type CoreLeafBlock = ContentBlock | CodeTabsBlock | StatsBlock | LinkGridBlock | ReferencesBlock | BannerBlock | TestimonialBlock | EventsBlock;
180
+ export type CoreLeafBlock = ContentBlock | CodeTabsBlock | StatsBlock | LinkGridBlock | ReferencesBlock | BannerBlock | TestimonialBlock | EventsBlock | LocationsBlock;
151
181
  export type CoreLeafType = CoreLeafBlock['type'];
152
182
  /** `A` is the wiki's atomic union: a container never holds another container. */
153
183
  export interface ColumnsBlock<A> {
@@ -313,6 +343,15 @@ export interface EventsData {
313
343
  export declare function eventsToMarkdown(block: EventsData): string;
314
344
  /** Events as prose, `12 Nov 2026: Group show, London` a line, under their headings. */
315
345
  export declare function eventsToText(block: EventsData): string;
346
+ /** The fields a locations block's text and twin read. */
347
+ export interface LocationsData {
348
+ heading?: string | null;
349
+ items: readonly PlaceItem[];
350
+ }
351
+ /** Each place under its name, its hours as rows, and none of whether it is open: a twin is read at no particular moment. */
352
+ export declare function locationsToMarkdown({ heading, items }: LocationsData): string;
353
+ /** Each place as prose: its name, address, hours a row to a line, note, phone and email. */
354
+ export declare function locationsToText({ heading, items }: LocationsData): string;
316
355
  /** A flat bullet list of links — resolved page lists, feed items, link rails. */
317
356
  export declare function linkList(items: readonly LinkGridLink[]): string;
318
357
  /**
@@ -354,7 +393,9 @@ export type CoreAtomicBlock<P = ResolvedPageRef> = {
354
393
  role?: string | null;
355
394
  } | ({
356
395
  type: 'events';
357
- } & EventsData) | {
396
+ } & EventsData) | ({
397
+ type: 'locations';
398
+ } & LocationsData) | {
358
399
  type: 'recentPages' | 'pageList';
359
400
  resolvedPages?: readonly P[] | null;
360
401
  } | {
@@ -475,6 +516,13 @@ export interface CoreBlockDefaults {
475
516
  text: string;
476
517
  }[];
477
518
  };
519
+ locations: () => {
520
+ type: 'locations';
521
+ items: {
522
+ id: string;
523
+ hours: Hours[];
524
+ }[];
525
+ };
478
526
  }
479
527
  /**
480
528
  * The new-block factories for the core types, which all three wikis had typed
package/dist/blocks.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // keeps this file free of any one repo's type union.
13
13
  import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
14
14
  import { relativeTime } from './freshness.js';
15
+ import { hoursRows, mapHref } from './hours.js';
15
16
  import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, referencesToText, statsToText, stripHtml, testimonialToText, } from './text.js';
16
17
  // ---- the shapes the standard block types carry ------------------------------
17
18
  /**
@@ -266,6 +267,41 @@ export function eventsToText(block) {
266
267
  .map(s => `${s.heading}\n${s.items.map(e => `${eventWhen(e)}: ${stripHtml(e.text).replace(/\s+/g, ' ')}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
267
268
  .join('\n\n');
268
269
  }
270
+ const tel = (phone) => `tel:${phone.replace(/[^\d+]/g, '')}`;
271
+ const addressLine = (address) => address?.trim().split(/\n+/).join(', ');
272
+ /** Each place under its name, its hours as rows, and none of whether it is open: a twin is read at no particular moment. */
273
+ export function locationsToMarkdown({ heading, items }) {
274
+ const level = heading ? '###' : '##';
275
+ const place = (p) => {
276
+ const map = mapHref(p);
277
+ const address = addressLine(p.address);
278
+ return [
279
+ p.name && `${level} ${p.name}`,
280
+ p.image && `![](${p.image})`,
281
+ address && (map ? `[${address}](${map})` : address),
282
+ p.hours?.length && hoursRows(p.hours).map(r => `- ${r.label}: ${r.times}`).join('\n'),
283
+ p.note && inlineToMarkdown(p.note),
284
+ [p.phone && `[${p.phone}](${tel(p.phone)})`, p.email && `[${p.email}](mailto:${p.email})`].filter(Boolean).join(' · '),
285
+ ]
286
+ .filter(Boolean)
287
+ .join('\n\n');
288
+ };
289
+ return [heading && `## ${heading}`, ...items.map(place)].filter(Boolean).join('\n\n');
290
+ }
291
+ /** Each place as prose: its name, address, hours a row to a line, note, phone and email. */
292
+ export function locationsToText({ heading, items }) {
293
+ const place = (p) => [
294
+ p.name,
295
+ addressLine(p.address),
296
+ ...(p.hours?.length ? hoursRows(p.hours).map(r => `${r.label} ${r.times}`) : []),
297
+ p.note && stripHtml(p.note).replace(/\s+/g, ' '),
298
+ p.phone,
299
+ p.email,
300
+ ]
301
+ .filter(Boolean)
302
+ .join('\n');
303
+ return [heading, ...items.map(place)].filter(Boolean).join('\n\n');
304
+ }
269
305
  /** A flat bullet list of links — resolved page lists, feed items, link rails. */
270
306
  export function linkList(items) {
271
307
  return items.map(l => `- [${l.label}](${l.href})`).join('\n');
@@ -285,6 +321,7 @@ export function coreAtomicText(block) {
285
321
  case 'linkGrid': return linkGridToText(block.groups, block.intro);
286
322
  case 'testimonial': return testimonialToText(block.quote, block.author, block.role);
287
323
  case 'events': return eventsToText(block);
324
+ case 'locations': return locationsToText(block);
288
325
  case 'recentPages':
289
326
  case 'pageList': return pageListToText(block.resolvedPages ?? []);
290
327
  case 'list': return pageListToText(block.items ?? []);
@@ -301,6 +338,7 @@ export function coreAtomicToMarkdown(block, opts) {
301
338
  case 'linkGrid': return linkGridToMarkdown(block.groups, block.intro);
302
339
  case 'testimonial': return testimonialToMarkdown(block.quote, block.author, block.role);
303
340
  case 'events': return eventsToMarkdown(block);
341
+ case 'locations': return locationsToMarkdown(block);
304
342
  case 'recentPages':
305
343
  case 'pageList':
306
344
  case 'list':
@@ -345,5 +383,7 @@ export function coreBlockDefaults(opts = {}) {
345
383
  testimonial: () => ({ type: 'testimonial', quote: '', author: '' }),
346
384
  // Dated this month, so the new row is valid as it stands and reads as coming up.
347
385
  events: () => ({ type: 'events', items: [{ id: newId(), date: today().slice(0, 7), text: '' }] }),
386
+ // Open on weekdays, so the new place is valid before it has its address.
387
+ locations: () => ({ type: 'locations', items: [{ id: newId(), hours: [{ days: ['mon', 'tue', 'wed', 'thu', 'fri'], opens: '09:00', closes: '17:00' }] }] }),
348
388
  };
349
389
  }
@@ -0,0 +1,51 @@
1
+ export declare const DAYS: readonly ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
2
+ export type Day = (typeof DAYS)[number];
3
+ /** Each day's whole name, as schema.org and a screen reader say it. */
4
+ export declare const DAY_NAMES: string[];
5
+ /** A time of day, 24-hour: "08:00", "17:30". A closing time at or before the opening one is the next morning's. */
6
+ export declare const TIME: RegExp;
7
+ /** Where a place's clock is when it names none. */
8
+ export declare const DEFAULT_ZONE = "Europe/London";
9
+ export type Hours = {
10
+ days: Day[];
11
+ opens: string;
12
+ closes: string;
13
+ };
14
+ export declare const isZone: (zone: string) => boolean;
15
+ /**
16
+ * The week as rows, neighbouring days that keep the same hours set as one:
17
+ * "Mon–Fri 8am–6pm", "Sat 9am–4pm", "Sun Closed". `days` names each row's
18
+ * days, so the visitor's own day can be marked.
19
+ */
20
+ export declare function hoursRows(hours: Hours[]): {
21
+ days: Day[];
22
+ label: string;
23
+ times: string;
24
+ }[];
25
+ /** The day of the week and the minute of it, by the clock at `zone`. */
26
+ export declare function clock(now: Date, zone: string): {
27
+ day: "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
28
+ minute: number;
29
+ };
30
+ /** Whether a place is open at `minute` into its week, and the words that say so: "Open · closes 6pm", "Closed · opens Mon 8am". */
31
+ export declare function openState(hours: Hours[], minute: number): {
32
+ open: boolean;
33
+ words: string;
34
+ } | null;
35
+ /** schema.org's OpeningHoursSpecification for each row of hours. */
36
+ export declare const hoursLd: (hours: Hours[]) => {
37
+ '@type': string;
38
+ dayOfWeek: string[];
39
+ opens: string;
40
+ closes: string;
41
+ }[];
42
+ /** Where a place's address is on a map: the link it was given, or a search for its words. */
43
+ export declare const mapHref: (place: {
44
+ address?: string;
45
+ map?: string;
46
+ }) => string | undefined;
47
+ /** A map of the place to set in the page, which needs no key: where its map link points, if that can be read, or else its address. */
48
+ export declare const mapEmbed: (place: {
49
+ address: string;
50
+ map?: string;
51
+ }) => string;
package/dist/hours.js ADDED
@@ -0,0 +1,114 @@
1
+ // hours.ts — a place's opening hours: how they read, a week to a few rows, and
2
+ // whether it is open at a given moment by its own clock. Pure, so a server
3
+ // render, a page's twin, its structured data and the visitor's browser all
4
+ // read them the same way. miow had them first; every app here has the
5
+ // `locations` block now.
6
+ import { toMapEmbedUrl } from './maps.js';
7
+ export const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
8
+ const NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
9
+ /** Each day's whole name, as schema.org and a screen reader say it. */
10
+ export const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
11
+ /** A time of day, 24-hour: "08:00", "17:30". A closing time at or before the opening one is the next morning's. */
12
+ export const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
13
+ /** Where a place's clock is when it names none. */
14
+ export const DEFAULT_ZONE = 'Europe/London';
15
+ export const isZone = (zone) => {
16
+ try {
17
+ new Intl.DateTimeFormat('en-GB', { timeZone: zone });
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ };
24
+ const minutes = (t) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3));
25
+ const WEEK = 7 * 1440;
26
+ /** "8am", "5:30pm", "noon", "midnight". */
27
+ function timeWords(t) {
28
+ const m = minutes(t);
29
+ if (m === 0)
30
+ return 'midnight';
31
+ if (m === 720)
32
+ return 'noon';
33
+ const h = Math.floor(m / 60) % 12 || 12;
34
+ return `${h}${m % 60 ? `:${String(m % 60).padStart(2, '0')}` : ''}${m < 720 ? 'am' : 'pm'}`;
35
+ }
36
+ /** Each opening as minutes into the week from Monday's midnight, an overnight one running into the next day. */
37
+ function spans(hours) {
38
+ return hours
39
+ .flatMap(h => h.days.map(d => {
40
+ const start = DAYS.indexOf(d) * 1440 + minutes(h.opens);
41
+ const length = (minutes(h.closes) - minutes(h.opens) + 1440) % 1440 || 1440;
42
+ return { start, end: start + length };
43
+ }))
44
+ .sort((a, b) => a.start - b.start);
45
+ }
46
+ /**
47
+ * The week as rows, neighbouring days that keep the same hours set as one:
48
+ * "Mon–Fri 8am–6pm", "Sat 9am–4pm", "Sun Closed". `days` names each row's
49
+ * days, so the visitor's own day can be marked.
50
+ */
51
+ export function hoursRows(hours) {
52
+ const day = DAYS.map(d => hours
53
+ .filter(h => h.days.includes(d))
54
+ .sort((a, b) => a.opens.localeCompare(b.opens))
55
+ .map(h => (h.opens === h.closes ? 'Open 24 hours' : `${timeWords(h.opens)}–${timeWords(h.closes)}`))
56
+ .join(', '));
57
+ const rows = [];
58
+ day.forEach((times, i) => {
59
+ const last = rows.at(-1);
60
+ if (last && last.times === (times || 'Closed'))
61
+ last.days.push(DAYS[i]);
62
+ else
63
+ rows.push({ days: [DAYS[i]], label: '', times: times || 'Closed' });
64
+ });
65
+ for (const r of rows) {
66
+ const [first, last] = [DAYS.indexOf(r.days[0]), DAYS.indexOf(r.days.at(-1))];
67
+ r.label = r.days.length === 7 ? 'Every day' : first === last ? NAMES[first] : last === first + 1 ? `${NAMES[first]}, ${NAMES[last]}` : `${NAMES[first]}–${NAMES[last]}`;
68
+ }
69
+ return rows;
70
+ }
71
+ /** The day of the week and the minute of it, by the clock at `zone`. */
72
+ export function clock(now, zone) {
73
+ const parts = new Intl.DateTimeFormat('en-GB', { timeZone: zone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(now);
74
+ const part = (type) => parts.find(p => p.type === type)?.value ?? '';
75
+ const day = NAMES.indexOf(part('weekday'));
76
+ return { day: DAYS[day], minute: day * 1440 + Number(part('hour')) * 60 + Number(part('minute')) };
77
+ }
78
+ /** Whether a place is open at `minute` into its week, and the words that say so: "Open · closes 6pm", "Closed · opens Mon 8am". */
79
+ export function openState(hours, minute) {
80
+ const all = spans(hours);
81
+ if (!all.length)
82
+ return null;
83
+ // Last week's overnight opening may still be running into this Monday, and this Sunday's into next week.
84
+ const runs = all.flatMap(s => [-WEEK, 0, WEEK].map(w => ({ start: s.start + w, end: s.end + w })));
85
+ const now = runs.find(s => s.start <= minute && minute < s.end);
86
+ if (now) {
87
+ // Openings that meet run on as one: a shop open round the clock never closes.
88
+ let end = now.end;
89
+ for (let next; (next = runs.find(s => s.start <= end && s.end > end)) && end - minute < WEEK;)
90
+ end = next.end;
91
+ if (end - minute >= WEEK)
92
+ return { open: true, words: 'Open 24 hours' };
93
+ return { open: true, words: `Open · closes ${timeWords(clockTime(end))}` };
94
+ }
95
+ const next = all.map(s => (s.start > minute ? s.start : s.start + WEEK)).sort((a, b) => a - b)[0];
96
+ const days = Math.floor(next / 1440) - Math.floor(minute / 1440);
97
+ const when = days === 0 ? '' : days === 1 ? 'tomorrow ' : `${NAMES[Math.floor(next / 1440) % 7]} `;
98
+ return { open: false, words: `Closed · opens ${when}${timeWords(clockTime(next))}` };
99
+ }
100
+ const clockTime = (minute) => {
101
+ const m = ((minute % 1440) + 1440) % 1440;
102
+ return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
103
+ };
104
+ /** schema.org's OpeningHoursSpecification for each row of hours. */
105
+ export const hoursLd = (hours) => hours.map(h => ({
106
+ '@type': 'OpeningHoursSpecification',
107
+ dayOfWeek: h.days.map(d => `https://schema.org/${DAY_NAMES[DAYS.indexOf(d)]}`),
108
+ opens: h.opens,
109
+ closes: h.closes,
110
+ }));
111
+ /** Where a place's address is on a map: the link it was given, or a search for its words. */
112
+ export const mapHref = (place) => place.map || (place.address ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.address.replace(/\n+/g, ', '))}` : undefined);
113
+ /** A map of the place to set in the page, which needs no key: where its map link points, if that can be read, or else its address. */
114
+ export const mapEmbed = (place) => (place.map && toMapEmbedUrl(place.map)) || `https://maps.google.com/maps?q=${encodeURIComponent(place.address.replace(/\n+/g, ', '))}&z=15&output=embed`;
@@ -22,7 +22,7 @@ export interface HtmlSanitizerOptions {
22
22
  export declare function createHtmlSanitizer(options?: HtmlSanitizerOptions): (html: string) => string;
23
23
  /**
24
24
  * The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
25
- * descriptions, and `references` and `events` item text. Every other field those blocks
25
+ * descriptions, `references` and `events` item text, and a place's note. Every other field those blocks
26
26
  * carry renders as a React text node and is escaped already.
27
27
  *
28
28
  * Returns any other block untouched, so it drops straight into a
package/dist/sanitize.js CHANGED
@@ -157,7 +157,7 @@ const each = (v, map) => Array.isArray(v) ? v.map(item => (item && typeof item =
157
157
  const str = (v) => typeof v === 'string';
158
158
  /**
159
159
  * The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
160
- * descriptions, and `references` and `events` item text. Every other field those blocks
160
+ * descriptions, `references` and `events` item text, and a place's note. Every other field those blocks
161
161
  * carry renders as a React text node and is escaped already.
162
162
  *
163
163
  * Returns any other block untouched, so it drops straight into a
@@ -179,6 +179,8 @@ export function sanitizeCoreLeaf(block, clean) {
179
179
  case 'references':
180
180
  case 'events':
181
181
  return { ...b, items: each(b.items, i => (str(i.text) ? { ...i, text: clean(i.text) } : i)) };
182
+ case 'locations':
183
+ return { ...b, items: each(b.items, p => (str(p.note) ? { ...p, note: clean(p.note) } : p)) };
182
184
  default:
183
185
  return block;
184
186
  }
@@ -9,6 +9,7 @@
9
9
  // As elsewhere in this package, the dispatch stays with the caller so a new
10
10
  // block type is a compile error there until it is handled.
11
11
  import { coreBlockShape, EVENT_DATE, eventEndsBefore, LIST_SOURCES } from './blocks.js';
12
+ import { DAYS, isZone, TIME } from './hours.js';
12
13
  /**
13
14
  * An href acceptable to persist: http, https, mailto, tel, or a relative path or
14
15
  * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
@@ -88,6 +89,31 @@ export function validateEventItems(items) {
88
89
  (e.end === undefined || (typeof e.end === 'string' && EVENT_DATE.test(e.end))) &&
89
90
  !eventEndsBefore(e)));
90
91
  }
92
+ /** `[{ days, opens, closes }]` — a place's regular hours, each row one day or more and two times of day. @internal */
93
+ export function validateHours(hours) {
94
+ return (Array.isArray(hours) &&
95
+ hours.length <= 21 &&
96
+ hours.every(h => isRecord(h) &&
97
+ Array.isArray(h.days) &&
98
+ h.days.length > 0 &&
99
+ h.days.every(d => DAYS.includes(d)) &&
100
+ typeof h.opens === 'string' &&
101
+ TIME.test(h.opens) &&
102
+ typeof h.closes === 'string' &&
103
+ TIME.test(h.closes)));
104
+ }
105
+ /** `[{ id, name?, image?, address?, map?, phone?, email?, hours?, timeZone?, note? }]` — a locations block's places, each with its address or its hours. @internal */
106
+ export function validatePlaces(items) {
107
+ return (Array.isArray(items) &&
108
+ items.every(p => isRecord(p) &&
109
+ typeof p.id === 'string' &&
110
+ [p.name, p.address, p.phone, p.email, p.note].every(optionalString) &&
111
+ (p.image === undefined || okUrl(p.image)) &&
112
+ (p.map === undefined || okUrl(p.map)) &&
113
+ (p.hours === undefined || validateHours(p.hours)) &&
114
+ (p.timeZone === undefined || (typeof p.timeZone === 'string' && isZone(p.timeZone))) &&
115
+ (!!p.address?.trim() || (Array.isArray(p.hours) && p.hours.length > 0))));
116
+ }
91
117
  // ---- the core leaf set ------------------------------------------------------
92
118
  /**
93
119
  * The most rows an author-supplied count may ask for. Every `limit` and every
@@ -146,6 +172,8 @@ export function coreAtomicValidator(opts = {}) {
146
172
  return typeof b.quote === 'string' && typeof b.author === 'string' && optionalString(b.role) && optionalString(b.avatarUrl);
147
173
  case 'events':
148
174
  return optionalString(b.upcoming) && optionalString(b.past) && validateEventItems(b.items);
175
+ case 'locations':
176
+ return optionalString(b.heading) && (b.map === undefined || b.map === true) && validatePlaces(b.items);
149
177
  default:
150
178
  return false;
151
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.88.0",
3
+ "version": "0.89.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -172,6 +172,11 @@
172
172
  "import": "./dist/freshness.js",
173
173
  "default": "./dist/freshness.js"
174
174
  },
175
+ "./hours": {
176
+ "types": "./dist/hours.d.ts",
177
+ "import": "./dist/hours.js",
178
+ "default": "./dist/hours.js"
179
+ },
175
180
  "./text": {
176
181
  "types": "./dist/text.d.ts",
177
182
  "import": "./dist/text.js",