wiki-formant 0.112.1 → 0.114.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 +21 -1
- package/dist/banner.d.ts +48 -0
- package/dist/banner.js +75 -0
- package/dist/base.css +39 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.js +3 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ await store.save({ tagPath, slug, content, authorId, message, baseVersion: page.
|
|
|
43
43
|
- **A save is one statement**: the page update and its revision in a CTE guarded on the version read. A lost race, a stale `baseVersion`, a page deleted since or a rename onto a taken slug writes nothing and throws a 409 `HttpError` whose body names the reason as `conflict`: `stale` and `deleted` mean reload, `taken` means another slug (`ConflictReason`).
|
|
44
44
|
- **The bump is the diff's**: added, removed or moved blocks are `major`, changed prose or title `minor`; `change` overrides it. A metadata-only save moves no version and writes no revision.
|
|
45
45
|
- **History is diffed on read.** A revision stores the page as saved. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown has its real predecessor.
|
|
46
|
-
- **Listings carry a snippet, not the article**: `list`, `recent` and `byIds` drop `content
|
|
46
|
+
- **Listings carry a snippet, not the article**: `list`, `recent` and `byIds` drop `content`, and carry `banner`, the page's picture for its card (`metadata.banner`, or the `banner` option's reading).
|
|
47
47
|
- **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres' headline as the snippet. Hidden paths, and paths under them, are left out unless named.
|
|
48
48
|
- `branch` is an agent's walk of a path and everything under it; `counts` is pages per tag path.
|
|
49
49
|
- Copy the models with `npx prisma-models wiki` (`--tenant` for a tenant-keyed copy), then build the full-text column with `npx fts-ddl '"WikiPage"'`.
|
|
@@ -466,6 +466,26 @@ return new ImageResponse(<SocialCard {...{ title, eyebrow, description }} {...fi
|
|
|
466
466
|
- Fonts stay with the site: Next traces a font file only from a literal path in the app, and satori reads TTF or OTF.
|
|
467
467
|
- `wiki-formant/seeded`'s `hashStr` and `seededRandom` are the deterministic randomness the art draws from, identical on server and browser.
|
|
468
468
|
|
|
469
|
+
## Page banners
|
|
470
|
+
|
|
471
|
+
`wiki-formant/banner`: the banner a page is headed with, its own picture or art seeded by the page.
|
|
472
|
+
|
|
473
|
+
```tsx
|
|
474
|
+
import { PageBanner, CardBanner } from 'wiki-formant/banner';
|
|
475
|
+
|
|
476
|
+
<PageBanner seed={page.title + page.tagPath} picture={banner && <Image src={banner} alt="" fill preload sizes="100vw" />}>
|
|
477
|
+
<Breadcrumbs items={crumbs} />
|
|
478
|
+
<h1>{page.title}</h1>
|
|
479
|
+
</PageBanner>
|
|
480
|
+
<CardBanner seed={page.title + page.tagPath} picture={card.banner && <Image src={card.banner} alt="" fill sizes="33vw" />} />
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
- Every page has a banner: a page without a picture gets art seeded by `seed`, the same on every render, server and browser. `CardBanner` is the same picture or art at the top of the page's card, cropped to 4:1; seed both alike, so a card and its page match.
|
|
484
|
+
- A listing carries `banner`, the picture both show: `metadata.banner` unless the store's `banner` option reads it elsewhere, such as the article's first picture. `BannerArt` alone is the art for any other frame.
|
|
485
|
+
- The art is inline SVG with a class per role, coloured by custom properties: `--banner-from` and `--banner-to` the gradient (transparent unset), `--banner-mid` the blobs and half the shapes (`--ui-muted` unset), `--banner-ink` the rest and the dots (`--ui-accent` unset), over `--banner-field` (`--ui-surface` unset). A category's palette or a section's band is a style on the frame.
|
|
486
|
+
- `mark` replaces the triangle with a wiki's own shape, as a path from its anchor, size and generator. `fill-opacity` on a role's class dims it under a title.
|
|
487
|
+
- The frame's 4:1 is a floor, not a height (`base.css`): the trail and title grow it rather than being cropped.
|
|
488
|
+
|
|
469
489
|
## Revisions and history
|
|
470
490
|
|
|
471
491
|
`wiki-formant/revisions`: what changed between two versions of a page, and which bump to ask for. `wiki-formant/history`: what each revision changed, as a history view shows it.
|
package/dist/banner.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CSSProperties, ReactElement, ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The art's third kind of shape, as a path: a wiki's own mark. Given its
|
|
4
|
+
* anchor, its size and the generator, so a mark that turns draws its turn
|
|
5
|
+
* from the same sequence. The default is a triangle round the anchor.
|
|
6
|
+
*/
|
|
7
|
+
export type BannerMark = (x: number, y: number, size: number, rand: () => number) => string;
|
|
8
|
+
/**
|
|
9
|
+
* The seeded field: a gradient at a seeded angle, three soft blobs, five to
|
|
10
|
+
* eight shapes, four bright dots. Its colours are custom properties
|
|
11
|
+
* (`base.css`): `--banner-from` and `--banner-to` the gradient, transparent
|
|
12
|
+
* unless set, `--banner-mid` the blobs and half the shapes, `--banner-ink`
|
|
13
|
+
* the other half and the dots.
|
|
14
|
+
*/
|
|
15
|
+
export declare function BannerArt({ seed, mark, className }: {
|
|
16
|
+
seed: string;
|
|
17
|
+
mark?: BannerMark;
|
|
18
|
+
className?: string;
|
|
19
|
+
}): ReactElement;
|
|
20
|
+
/**
|
|
21
|
+
* A page's header: its picture, else its art, with the page's trail and title
|
|
22
|
+
* over it. `picture` is the app's own image element (`next/image` with `fill`,
|
|
23
|
+
* say), since this package has no `next`; it fills the frame. The frame's
|
|
24
|
+
* height is a floor of 4:1 that grows with what sits on it (`base.css`), so a
|
|
25
|
+
* long title on a phone never loses its trail. `style` carries a palette's
|
|
26
|
+
* custom properties.
|
|
27
|
+
*/
|
|
28
|
+
export declare function PageBanner({ seed, picture, mark, className, style, children }: {
|
|
29
|
+
seed: string;
|
|
30
|
+
picture?: ReactNode;
|
|
31
|
+
mark?: BannerMark;
|
|
32
|
+
className?: string;
|
|
33
|
+
style?: CSSProperties;
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
}): ReactElement;
|
|
36
|
+
/**
|
|
37
|
+
* The same banner at the top of a page's card: its picture, else its art,
|
|
38
|
+
* in a 4:1 frame (`base.css`). Seed it as the page's header is seeded, so a
|
|
39
|
+
* card and the page it opens wear the same art. Decorative: the card's title
|
|
40
|
+
* names it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function CardBanner({ seed, picture, mark, className, style }: {
|
|
43
|
+
seed: string;
|
|
44
|
+
picture?: ReactNode;
|
|
45
|
+
mark?: BannerMark;
|
|
46
|
+
className?: string;
|
|
47
|
+
style?: CSSProperties;
|
|
48
|
+
}): ReactElement;
|
package/dist/banner.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { hashStr, seededRandom } from './seeded.js';
|
|
3
|
+
const W = 800;
|
|
4
|
+
const H = 200;
|
|
5
|
+
const px = (v) => Math.round(v);
|
|
6
|
+
const op = (v) => v.toFixed(2);
|
|
7
|
+
const triangle = (x, y, s, rand) => `M${Array.from({ length: 3 }, () => `${px(x + rand() * s * 2 - s)} ${px(y + rand() * s * 2 - s)}`).join('L')}Z`;
|
|
8
|
+
// Static strings, so a class checker sees every one.
|
|
9
|
+
const FILL = ['banner-art__mid', 'banner-art__ink'];
|
|
10
|
+
const LINE = ['banner-art__mid-line', 'banner-art__ink-line'];
|
|
11
|
+
/**
|
|
12
|
+
* The seeded field: a gradient at a seeded angle, three soft blobs, five to
|
|
13
|
+
* eight shapes, four bright dots. Its colours are custom properties
|
|
14
|
+
* (`base.css`): `--banner-from` and `--banner-to` the gradient, transparent
|
|
15
|
+
* unless set, `--banner-mid` the blobs and half the shapes, `--banner-ink`
|
|
16
|
+
* the other half and the dots.
|
|
17
|
+
*/
|
|
18
|
+
export function BannerArt({ seed, mark = triangle, className = 'banner-art' }) {
|
|
19
|
+
const hash = hashStr(seed);
|
|
20
|
+
const rand = seededRandom(hash);
|
|
21
|
+
const id = `banner-${hash.toString(36)}`;
|
|
22
|
+
const shapes = [];
|
|
23
|
+
const angle = Math.floor(rand() * 360);
|
|
24
|
+
for (let i = 0; i < 3; i++) {
|
|
25
|
+
shapes.push(_jsx("circle", { className: FILL[0], cx: px(rand() * W), cy: px(rand() * H), r: px(60 + rand() * 120), opacity: op(0.15 + rand() * 0.2) }, `blob${i}`));
|
|
26
|
+
}
|
|
27
|
+
const count = 5 + Math.floor(rand() * 4);
|
|
28
|
+
for (let i = 0; i < count; i++) {
|
|
29
|
+
const x = rand() * W;
|
|
30
|
+
const y = rand() * H;
|
|
31
|
+
const tone = i % 2 === 0 ? 1 : 0;
|
|
32
|
+
const opacity = op(0.12 + rand() * 0.25);
|
|
33
|
+
const kind = Math.floor(rand() * 4);
|
|
34
|
+
const key = `shape${i}`;
|
|
35
|
+
if (kind === 0) {
|
|
36
|
+
shapes.push(_jsx("circle", { className: FILL[tone], cx: px(x), cy: px(y), r: px(8 + rand() * 40), opacity: opacity }, key));
|
|
37
|
+
}
|
|
38
|
+
else if (kind === 1) {
|
|
39
|
+
const w = px(20 + rand() * 80), h = px(15 + rand() * 50), rx = px(rand() * 12);
|
|
40
|
+
const turn = px(rand() * 60 - 30);
|
|
41
|
+
shapes.push(_jsx("rect", { className: FILL[tone], x: px(x), y: px(y), width: w, height: h, rx: rx, opacity: opacity, transform: `rotate(${turn} ${px(x)} ${px(y)})` }, key));
|
|
42
|
+
}
|
|
43
|
+
else if (kind === 2) {
|
|
44
|
+
shapes.push(_jsx("path", { className: FILL[tone], d: mark(x, y, 15 + rand() * 35, rand), opacity: opacity }, key));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const r = px(12 + rand() * 30);
|
|
48
|
+
shapes.push(_jsx("circle", { className: LINE[tone], cx: px(x), cy: px(y), r: r, strokeWidth: (2 + rand() * 4).toFixed(1), opacity: opacity }, key));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < 4; i++) {
|
|
52
|
+
shapes.push(_jsx("circle", { className: FILL[1], cx: px(rand() * W), cy: px(rand() * H), r: px(2 + rand() * 6), opacity: op(0.4 + rand() * 0.4) }, `dot${i}`));
|
|
53
|
+
}
|
|
54
|
+
return (_jsxs("svg", { className: className, viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "xMidYMid slice", "aria-hidden": true, focusable: "false", children: [_jsx("defs", { children: _jsxs("linearGradient", { id: id, gradientTransform: `rotate(${angle})`, children: [_jsx("stop", { offset: "0%", className: "banner-art__from" }), _jsx("stop", { offset: "100%", className: "banner-art__to" })] }) }), _jsx("rect", { width: W, height: H, fill: `url(#${id})` }), shapes] }));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A page's header: its picture, else its art, with the page's trail and title
|
|
58
|
+
* over it. `picture` is the app's own image element (`next/image` with `fill`,
|
|
59
|
+
* say), since this package has no `next`; it fills the frame. The frame's
|
|
60
|
+
* height is a floor of 4:1 that grows with what sits on it (`base.css`), so a
|
|
61
|
+
* long title on a phone never loses its trail. `style` carries a palette's
|
|
62
|
+
* custom properties.
|
|
63
|
+
*/
|
|
64
|
+
export function PageBanner({ seed, picture, mark, className, style, children }) {
|
|
65
|
+
return (_jsxs("div", { className: className ? `page-banner ${className}` : 'page-banner', style: style, children: [picture ?? _jsx(BannerArt, { seed: seed, mark: mark }), children && _jsx("div", { className: "page-banner__body", children: children })] }));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The same banner at the top of a page's card: its picture, else its art,
|
|
69
|
+
* in a 4:1 frame (`base.css`). Seed it as the page's header is seeded, so a
|
|
70
|
+
* card and the page it opens wear the same art. Decorative: the card's title
|
|
71
|
+
* names it.
|
|
72
|
+
*/
|
|
73
|
+
export function CardBanner({ seed, picture, mark, className, style }) {
|
|
74
|
+
return (_jsx("span", { className: className ? `card-banner ${className}` : 'card-banner', style: style, "aria-hidden": true, children: picture ?? _jsx(BannerArt, { seed: seed, mark: mark }) }));
|
|
75
|
+
}
|
package/dist/base.css
CHANGED
|
@@ -463,6 +463,45 @@ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linea
|
|
|
463
463
|
}
|
|
464
464
|
.breadcrumbs .separator { user-select: none; }
|
|
465
465
|
|
|
466
|
+
/* ---- PageBanner, CardBanner and BannerArt -----------------------------------------------
|
|
467
|
+
4:1 is a floor, not a height: the ratio-holder and the body share one grid
|
|
468
|
+
cell, so the row is as tall as whichever needs more, and a long title on a
|
|
469
|
+
phone grows the banner rather than losing its trail off the top. The column
|
|
470
|
+
is minmax(0, 1fr), not auto, which would size to a long title's unwrapped
|
|
471
|
+
line. `max-height` caps only the picture, never the words. */
|
|
472
|
+
.page-banner {
|
|
473
|
+
position: relative;
|
|
474
|
+
display: grid;
|
|
475
|
+
grid-template-columns: minmax(0, 1fr);
|
|
476
|
+
overflow: hidden;
|
|
477
|
+
border-radius: var(--ui-radius);
|
|
478
|
+
background: var(--banner-field, var(--ui-surface));
|
|
479
|
+
}
|
|
480
|
+
.page-banner::before { content: ''; grid-area: 1 / 1; aspect-ratio: 4 / 1; max-height: 12rem; }
|
|
481
|
+
.page-banner > :is(img, picture, svg) { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
|
482
|
+
/* `relative` only so it paints above the picture. */
|
|
483
|
+
.page-banner__body {
|
|
484
|
+
grid-area: 1 / 1;
|
|
485
|
+
position: relative;
|
|
486
|
+
display: flex;
|
|
487
|
+
flex-direction: column;
|
|
488
|
+
justify-content: flex-end;
|
|
489
|
+
gap: 0.5rem;
|
|
490
|
+
min-width: 0;
|
|
491
|
+
padding: var(--ui-pad);
|
|
492
|
+
}
|
|
493
|
+
.page-banner__body > :is(h1, nav) { margin: 0; }
|
|
494
|
+
/* CardBanner: the same picture or art at the top of a card, cropped to 4:1. */
|
|
495
|
+
.card-banner { position: relative; display: block; aspect-ratio: 4 / 1; overflow: hidden; background: var(--banner-field, var(--ui-surface)); }
|
|
496
|
+
.card-banner > :is(img, picture, svg) { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
|
497
|
+
.banner-art { display: block; width: 100%; height: 100%; }
|
|
498
|
+
.banner-art__from { stop-color: var(--banner-from, transparent); }
|
|
499
|
+
.banner-art__to { stop-color: var(--banner-to, transparent); }
|
|
500
|
+
.banner-art__mid { fill: var(--banner-mid, var(--ui-muted)); }
|
|
501
|
+
.banner-art__ink { fill: var(--banner-ink, var(--ui-accent)); }
|
|
502
|
+
.banner-art__mid-line { fill: none; stroke: var(--banner-mid, var(--ui-muted)); }
|
|
503
|
+
.banner-art__ink-line { fill: none; stroke: var(--banner-ink, var(--ui-accent)); }
|
|
504
|
+
|
|
466
505
|
/* ---- Pager and SnapDots -----------------------------------------------------
|
|
467
506
|
One line that never wraps, the count in tabular figures so stepping does not
|
|
468
507
|
shift it. A step is a bare control: its colour, size and hit area are the
|
package/dist/store.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface WikiPageRow<B = unknown> {
|
|
|
26
26
|
*/
|
|
27
27
|
export type WikiPageListing = Omit<WikiPageRow, 'content'> & {
|
|
28
28
|
snippet: string;
|
|
29
|
+
banner: string | null;
|
|
29
30
|
};
|
|
30
31
|
export interface WikiPageRef {
|
|
31
32
|
tagPath: string;
|
|
@@ -72,6 +73,12 @@ export interface WikiStoreOptions<B extends DiffBlock> {
|
|
|
72
73
|
* article's opening; a wiki that keeps a written summary in metadata says so.
|
|
73
74
|
*/
|
|
74
75
|
snippet?: (page: Pick<WikiPageRow<B>, 'content' | 'metadata'>) => string;
|
|
76
|
+
/**
|
|
77
|
+
* The picture a page's banner and card show, if it has one. Defaults to
|
|
78
|
+
* `metadata.banner`; a wiki whose pages carry no banner of their own may
|
|
79
|
+
* read the article's first picture instead.
|
|
80
|
+
*/
|
|
81
|
+
banner?: (page: Pick<WikiPageRow<B>, 'content' | 'metadata'>) => string | null | undefined;
|
|
75
82
|
}
|
|
76
83
|
export interface SaveInput<B> {
|
|
77
84
|
tagPath: string;
|
|
@@ -173,6 +180,7 @@ export declare function wikiStore<B extends DiffBlock = DiffBlock>(o: WikiStoreO
|
|
|
173
180
|
createdAt: Date;
|
|
174
181
|
updatedAt: Date;
|
|
175
182
|
snippet: string;
|
|
183
|
+
banner: string | null;
|
|
176
184
|
}[];
|
|
177
185
|
total: number;
|
|
178
186
|
}>;
|
package/dist/store.js
CHANGED
|
@@ -44,9 +44,10 @@ export function orderByIds(rows, ids) {
|
|
|
44
44
|
export function wikiStore(o) {
|
|
45
45
|
const site = o.siteId ?? '';
|
|
46
46
|
const opening = o.snippet ?? ((page) => describe(openingText(page.content)));
|
|
47
|
+
const bannerOf = o.banner ?? ((page) => (typeof page.metadata?.banner === 'string' ? page.metadata.banner : null));
|
|
47
48
|
const listing = (row) => {
|
|
48
49
|
const { content: _, ...rest } = row;
|
|
49
|
-
return { ...rest, snippet: opening(row) };
|
|
50
|
+
return { ...rest, snippet: opening(row), banner: bannerOf(row) ?? null };
|
|
50
51
|
};
|
|
51
52
|
const hidden = [...(o.hidden ?? [])];
|
|
52
53
|
const rows = async (query, ...values) => (await o.sql(query, ...values));
|
|
@@ -168,6 +169,7 @@ export function wikiStore(o) {
|
|
|
168
169
|
...row,
|
|
169
170
|
...(withContent ? { content: content } : {}),
|
|
170
171
|
score: tier,
|
|
172
|
+
banner: bannerOf({ content: content, metadata: row.metadata }) ?? null,
|
|
171
173
|
snippet: (headline ? decodeEntities(headline).replace(/\s+/g, ' ').trim() : '')
|
|
172
174
|
|| matchSnippet(content, query, () => opening({ content: content, metadata: row.metadata })),
|
|
173
175
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.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",
|
|
@@ -253,6 +253,11 @@
|
|
|
253
253
|
"import": "./dist/seeded.js",
|
|
254
254
|
"default": "./dist/seeded.js"
|
|
255
255
|
},
|
|
256
|
+
"./banner": {
|
|
257
|
+
"types": "./dist/banner.d.ts",
|
|
258
|
+
"import": "./dist/banner.js",
|
|
259
|
+
"default": "./dist/banner.js"
|
|
260
|
+
},
|
|
256
261
|
"./og": {
|
|
257
262
|
"types": "./dist/og.d.ts",
|
|
258
263
|
"import": "./dist/og.js",
|