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 +7 -5
- package/dist/base.css +28 -1
- package/dist/block-editor.d.ts +11 -1
- package/dist/block-editor.js +22 -0
- package/dist/block-views.d.ts +36 -1
- package/dist/block-views.js +34 -0
- package/dist/blocks.d.ts +50 -2
- package/dist/blocks.js +40 -0
- package/dist/hours.d.ts +51 -0
- package/dist/hours.js +114 -0
- package/dist/sanitize.d.ts +1 -1
- package/dist/sanitize.js +3 -1
- package/dist/validation.js +28 -0
- package/package.json +6 -1
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
|
|
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; }
|
package/dist/block-editor.d.ts
CHANGED
|
@@ -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;
|
package/dist/block-editor.js
CHANGED
|
@@ -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;
|
package/dist/block-views.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/block-views.js
CHANGED
|
@@ -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 && ``,
|
|
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
|
}
|
package/dist/hours.d.ts
ADDED
|
@@ -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`;
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
}
|
package/dist/validation.js
CHANGED
|
@@ -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.
|
|
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",
|