wiki-formant 0.113.0 → 0.115.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
@@ -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"'`.
@@ -471,16 +471,17 @@ return new ImageResponse(<SocialCard {...{ title, eyebrow, description }} {...fi
471
471
  `wiki-formant/banner`: the banner a page is headed with, its own picture or art seeded by the page.
472
472
 
473
473
  ```tsx
474
- import { PageBanner, BannerArt } from 'wiki-formant/banner';
474
+ import { PageBanner, CardBanner } from 'wiki-formant/banner';
475
475
 
476
476
  <PageBanner seed={page.title + page.tagPath} picture={banner && <Image src={banner} alt="" fill preload sizes="100vw" />}>
477
477
  <Breadcrumbs items={crumbs} />
478
478
  <h1>{page.title}</h1>
479
479
  </PageBanner>
480
- <span className="card-thumb"><BannerArt seed={page.title + page.tagPath} /></span>
480
+ <CardBanner seed={page.title + page.tagPath} picture={card.banner && <Image src={card.banner} alt="" fill sizes="33vw" />} />
481
481
  ```
482
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. Seed it as the page's card thumbnail is seeded, so the two match.
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.
484
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.
485
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.
486
487
  - The frame's 4:1 is a floor, not a height (`base.css`): the trail and title grow it rather than being cropped.
package/dist/banner.d.ts CHANGED
@@ -33,3 +33,16 @@ export declare function PageBanner({ seed, picture, mark, className, style, chil
33
33
  style?: CSSProperties;
34
34
  children?: ReactNode;
35
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 CHANGED
@@ -64,3 +64,12 @@ export function BannerArt({ seed, mark = triangle, className = 'banner-art' }) {
64
64
  export function PageBanner({ seed, picture, mark, className, style, children }) {
65
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
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
@@ -272,6 +272,17 @@ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linea
272
272
 
273
273
  .page-refs { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; overflow: hidden; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }
274
274
  .page-refs > li + li { border-top: 1px solid var(--ui-border); }
275
+ /* A row of the list: the page's name against the age of its last change, the
276
+ whole row the link. The box was drawn here and the row left to each wiki, so
277
+ three of them restated the same flex line and a fourth, styling none, ran a
278
+ name into its date. A long name is cut rather than push the date off a
279
+ phone's width, and the date keeps its width, or it wraps to a word a line. */
280
+ .page-ref { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; padding: 0.625rem var(--ui-pad); color: inherit; text-decoration: none; transition: background-color 150ms; }
281
+ .page-ref:hover { background: var(--ui-surface-2); }
282
+ .page-ref-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color 150ms; }
283
+ .page-ref:hover .page-ref-title { color: var(--ui-accent); }
284
+ .page-ref-time { flex-shrink: 0; font-size: var(--ui-small); color: var(--ui-muted); }
285
+ .page-refs-empty { color: var(--ui-muted); }
275
286
 
276
287
  .see-also-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 0.75rem; margin: 0; padding: 0; list-style: none; }
277
288
  .see-also-item { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; padding: 0.75rem; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); color: inherit; text-decoration: none; transition: border-color 150ms; }
@@ -463,7 +474,7 @@ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linea
463
474
  }
464
475
  .breadcrumbs .separator { user-select: none; }
465
476
 
466
- /* ---- PageBanner and BannerArt -----------------------------------------------
477
+ /* ---- PageBanner, CardBanner and BannerArt -----------------------------------------------
467
478
  4:1 is a floor, not a height: the ratio-holder and the body share one grid
468
479
  cell, so the row is as tall as whichever needs more, and a long title on a
469
480
  phone grows the banner rather than losing its trail off the top. The column
@@ -491,6 +502,9 @@ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linea
491
502
  padding: var(--ui-pad);
492
503
  }
493
504
  .page-banner__body > :is(h1, nav) { margin: 0; }
505
+ /* CardBanner: the same picture or art at the top of a card, cropped to 4:1. */
506
+ .card-banner { position: relative; display: block; aspect-ratio: 4 / 1; overflow: hidden; background: var(--banner-field, var(--ui-surface)); }
507
+ .card-banner > :is(img, picture, svg) { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
494
508
  .banner-art { display: block; width: 100%; height: 100%; }
495
509
  .banner-art__from { stop-color: var(--banner-from, transparent); }
496
510
  .banner-art__to { stop-color: var(--banner-to, transparent); }
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.113.0",
3
+ "version": "0.115.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",