wiki-formant 0.87.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`, `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. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
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
@@ -155,10 +155,12 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
155
155
  The structure three or four sites had each restated around this markup;
156
156
  padding, gaps, radii and colour stay theirs. A stats block is two columns,
157
157
  three or four from 40rem; a code-tab strip is a row of buttons; a banner sets
158
- its icon beside its text; a long address in an infobox wraps; an events
158
+ its icon beside its text; a long address in an infobox wraps; a testimonial
159
+ sets its photo, round, beside who said it; an events
159
160
  block sets every row's dates in one column, shared by Upcoming and Past
160
161
  through a subgrid, and stacks them over the words wherever it is narrower
161
- 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. */
162
164
  .stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
163
165
  @media (min-width: 40rem) {
164
166
  .stat-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@@ -174,6 +176,9 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
174
176
  sits outside the article's prose box, and on radix.wiki ran 709px. */
175
177
  .link-grid-group-description { max-width: var(--measure); }
176
178
  .infobox-series { display: flex; flex-direction: column; }
179
+ .testimonial figcaption { display: flex; align-items: center; gap: 0.75rem; }
180
+ .testimonial figcaption img { width: 2.5rem; height: 2.5rem; border-radius: 50%; object-fit: cover; }
181
+ .testimonial figcaption > span { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 0.5rem; }
177
182
  .events { display: grid; grid-template-columns: auto 1fr; container-type: inline-size; }
178
183
  .events > *, .event-list li { grid-column: 1 / -1; }
179
184
  .event-list, .event-list li { display: grid; grid-template-columns: subgrid; }
@@ -182,6 +187,16 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
182
187
  @container (max-width: 30rem) {
183
188
  :is(.event-list, .event-list li, .event-list time) { display: block; }
184
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; }
185
200
  .heading-anchor,
186
201
  .ref-backlink,
187
202
  .reference-link { text-decoration: none; }
@@ -308,6 +323,10 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
308
323
 
309
324
  .facet-summary { margin: 0; font-size: var(--ui-small); color: var(--ui-muted); }
310
325
 
326
+ .testimonial { margin: 1.5rem 0; }
327
+ .testimonial > blockquote { margin: 0 0 0.75rem; font-size: 1.125rem; }
328
+ .testimonial figcaption small { color: var(--ui-muted); }
329
+
311
330
  .events { column-gap: 1rem; margin-block: 1rem; }
312
331
  .events h2 { margin: 0 0 1rem; }
313
332
  .events ol ~ h2 { margin-top: 2.5rem; }
@@ -318,6 +337,22 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
318
337
  }
319
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; }
320
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
+
321
356
  .link-grid { display: flex; flex-direction: column; gap: 1rem; margin-block: 1rem; }
322
357
  .link-grid-group-description { margin: 0 0 0.75rem; font-size: var(--ui-small); color: var(--ui-muted); }
323
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 } 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;
@@ -167,6 +167,11 @@ export declare function StatsFields({ block, onUpdate, hint }: {
167
167
  onUpdate: (block: StatsBlock) => void;
168
168
  hint?: string;
169
169
  }): import("react").JSX.Element;
170
+ /** Someone's words exactly, and who they are: their name, and optionally their role and photo. */
171
+ export declare function TestimonialFields({ block, onUpdate }: {
172
+ block: TestimonialBlock;
173
+ onUpdate: (block: TestimonialBlock) => void;
174
+ }): import("react").JSX.Element;
170
175
  /**
171
176
  * Events in the order they were written, each with how its dates read and
172
177
  * where the page sets it today: the page sorts them itself, and moves each on
@@ -177,6 +182,16 @@ export declare function EventsFields({ block, onUpdate, hint }: {
177
182
  onUpdate: (block: EventsBlock) => void;
178
183
  hint?: string;
179
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;
180
195
  /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
181
196
  export declare function ReferencesFields({ block, onUpdate, title, noun, hint, }: {
182
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);
@@ -163,6 +164,11 @@ export function StatsFields({ block, onUpdate, hint }) {
163
164
  const set = (next) => onUpdate({ ...block, items: next });
164
165
  return (_jsxs("div", { className: c.stack, children: [_jsx(Choice, { label: "Per row", value: String(block.columns), options: ['2', '3', '4'].map(n => ({ value: n, label: n })), onChange: n => onUpdate({ ...block, columns: Number(n) }) }), items.map((item, i) => (_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Figure", className: c.figure, value: item.value, onChange: value => set(replaceAt(items, i, { ...item, value: value ?? '' })) }), _jsx(TextInput, { label: "What it counts", value: item.label, onChange: label => set(replaceAt(items, i, { ...item, label: label ?? '' })) }), _jsx(Remove, { label: `Remove figure ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }, item.id))), _jsx(Add, { onPress: () => set([...items, { id: newId(), value: '', label: '' }]), children: "Add figure" }), hint && _jsx("small", { className: c.muted, children: hint })] }));
165
166
  }
167
+ /** Someone's words exactly, and who they are: their name, and optionally their role and photo. */
168
+ export function TestimonialFields({ block, onUpdate }) {
169
+ const c = useEditorContext().kit.classNames;
170
+ return (_jsxs("div", { className: c.stack, children: [_jsx(Field, { label: "Their words", children: _jsx("textarea", { "aria-label": "Their words", placeholder: "Their words, exactly as they said them", rows: 3, className: c.input, value: block.quote, onChange: e => onUpdate({ ...block, quote: e.target.value }) }) }), _jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Who said them", value: block.author, onChange: author => onUpdate({ ...block, author: author ?? '' }) }), _jsx(TextInput, { label: "Their role (optional)", optional: true, value: block.role, onChange: role => onUpdate({ ...block, role }) })] }), _jsx(TextInput, { label: "Photo address (optional)", type: "url", placeholder: "https://", optional: true, value: block.avatarUrl, onChange: avatarUrl => onUpdate({ ...block, avatarUrl }) })] }));
171
+ }
166
172
  /**
167
173
  * Events in the order they were written, each with how its dates read and
168
174
  * where the page sets it today: the page sorts them itself, and moves each on
@@ -178,6 +184,27 @@ export function EventsFields({ block, onUpdate, hint }) {
178
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));
179
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.' })] }));
180
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
+ }
181
208
  /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
182
209
  export function ReferencesFields({ block, onUpdate, title = 'References', noun = 'reference', hint, }) {
183
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;
@@ -139,6 +169,16 @@ export interface BannerBlock {
139
169
  variant: BannerVariant;
140
170
  text?: string;
141
171
  }
172
+ /**
173
+ * The leaves every wiki here stores alike, as one union: a repo builds its own
174
+ * atomic union from it, `CoreLeafBlock | ListBlock | its native types`, so a
175
+ * leaf added here reaches every repo with the release, and every record keyed
176
+ * by its block types and every exhaustive switch over them fails to compile
177
+ * there until it is drawn, edited and labelled. The list is left out because
178
+ * each repo resolves its `items` to its own rows.
179
+ */
180
+ export type CoreLeafBlock = ContentBlock | CodeTabsBlock | StatsBlock | LinkGridBlock | ReferencesBlock | BannerBlock | TestimonialBlock | EventsBlock | LocationsBlock;
181
+ export type CoreLeafType = CoreLeafBlock['type'];
142
182
  /** `A` is the wiki's atomic union: a container never holds another container. */
143
183
  export interface ColumnsBlock<A> {
144
184
  id: string;
@@ -303,6 +343,15 @@ export interface EventsData {
303
343
  export declare function eventsToMarkdown(block: EventsData): string;
304
344
  /** Events as prose, `12 Nov 2026: Group show, London` a line, under their headings. */
305
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;
306
355
  /** A flat bullet list of links — resolved page lists, feed items, link rails. */
307
356
  export declare function linkList(items: readonly LinkGridLink[]): string;
308
357
  /**
@@ -344,7 +393,9 @@ export type CoreAtomicBlock<P = ResolvedPageRef> = {
344
393
  role?: string | null;
345
394
  } | ({
346
395
  type: 'events';
347
- } & EventsData) | {
396
+ } & EventsData) | ({
397
+ type: 'locations';
398
+ } & LocationsData) | {
348
399
  type: 'recentPages' | 'pageList';
349
400
  resolvedPages?: readonly P[] | null;
350
401
  } | {
@@ -465,6 +516,13 @@ export interface CoreBlockDefaults {
465
516
  text: string;
466
517
  }[];
467
518
  };
519
+ locations: () => {
520
+ type: 'locations';
521
+ items: {
522
+ id: string;
523
+ hours: Hours[];
524
+ }[];
525
+ };
468
526
  }
469
527
  /**
470
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.87.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",