stunning-md 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +275 -2
- package/dist/core.d.mts +377 -0
- package/dist/core.mjs +1481 -0
- package/dist/core.mjs.map +1 -0
- package/dist/index.d.mts +430 -0
- package/dist/index.mjs +3661 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server.d.mts +19 -0
- package/dist/server.mjs +38 -0
- package/dist/server.mjs.map +1 -0
- package/dist/styles.css +2 -0
- package/package.json +101 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Gorissen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,276 @@
|
|
|
1
|
-
#
|
|
1
|
+
# stunning-md
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Markdown in. A beautifully designed website out.
|
|
4
|
+
|
|
5
|
+
`stunning-md` is a React component. Give it a markdown string and it lays the document out section by section — from its structure, the size of its images and the shape of its tables — then themes it to suit what it says. A small classifier model can be consulted for the judgement calls structure cannot settle; without one, rules decide everything.
|
|
6
|
+
|
|
7
|
+
**[Try the live demo](https://serrynaimo.github.io/stunning-md/)** with your own file or one of the samples.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i stunning-md
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It needs React 19 or newer. It does not need Tailwind, shadcn/ui or any other setup in your app: the package ships its own stylesheet.
|
|
16
|
+
|
|
17
|
+
## Use it
|
|
18
|
+
|
|
19
|
+
Import the two stylesheets once, at the root of your app:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
// app/layout.tsx
|
|
23
|
+
import "stunning-md/styles.css"
|
|
24
|
+
import "katex/dist/katex.min.css" // maths; installed with stunning-md
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then render a document:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// app/page.tsx — a Next.js server component
|
|
31
|
+
import { readFile } from "node:fs/promises"
|
|
32
|
+
import { StunningMarkdown } from "stunning-md"
|
|
33
|
+
|
|
34
|
+
export default async function Page() {
|
|
35
|
+
const markdown = await readFile("content/report.md", "utf8")
|
|
36
|
+
return <StunningMarkdown markdown={markdown} />
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
That is the whole integration. The component is a client component (the package marks it so), so it can be rendered straight from a server component as above, or from any client component in a Vite or other React app.
|
|
41
|
+
|
|
42
|
+
It is meant to be the page: it brings its own top bar, hero and full-width sections, so give it the full width of the window rather than a narrow column.
|
|
43
|
+
|
|
44
|
+
### Images with relative paths
|
|
45
|
+
|
|
46
|
+
URLs in the markdown are used as written. If your documents refer to images by relative path, map them to something the browser can load:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
"use client"
|
|
50
|
+
import { StunningMarkdown } from "stunning-md"
|
|
51
|
+
|
|
52
|
+
const resolveUrl = (url: string) => (/^(https?:|data:|blob:|\/)/.test(url) ? url : `/content/${url}`)
|
|
53
|
+
|
|
54
|
+
export function Document({ markdown }: { markdown: string }) {
|
|
55
|
+
return <StunningMarkdown markdown={markdown} resolveUrl={resolveUrl} />
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Functions cannot cross from a server component to a client one, so props such as `resolveUrl`, `classifier` and `onPlan` are passed from a small client component like this. Define them outside the component (or memoise them) so they keep the same identity between renders.
|
|
60
|
+
|
|
61
|
+
### Adding a classifier (optional)
|
|
62
|
+
|
|
63
|
+
A [jev-compatible](https://huggingface.co/AnkitAI/TinyJev-4B) classifier lets a model choose the theme, decide whether a table's numbers are worth charting, and judge whether a leading image should be the hero. The API key must stay on your server, so the browser talks to a route of yours that adds it:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// app/api/classify/route.ts
|
|
67
|
+
import { createClassifierHandler } from "stunning-md/server"
|
|
68
|
+
|
|
69
|
+
export const POST = createClassifierHandler({
|
|
70
|
+
url: process.env.STUNNING_MD_CLASSIFIER_URL,
|
|
71
|
+
apiKey: process.env.STUNNING_MD_CLASSIFIER_KEY,
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
// app/document.tsx
|
|
77
|
+
"use client"
|
|
78
|
+
import { StunningMarkdown, createClassifier } from "stunning-md"
|
|
79
|
+
|
|
80
|
+
const classifier = createClassifier({ endpoint: "/api/classify" })
|
|
81
|
+
|
|
82
|
+
export function Document({ markdown }: { markdown: string }) {
|
|
83
|
+
return <StunningMarkdown markdown={markdown} classifier={classifier} />
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Any endpoint that accepts `{ state, questions }` with `choice`, `noul` and `score` question types and returns `{ answers }` will work, and `classifier` can be any function of the `Classify` type if you would rather call something else.
|
|
88
|
+
|
|
89
|
+
What is sent: the title, section headings and the first 400 characters of prose; up to eight tables (header and first twelve rows each); and the leading image's alt text, file name and dimensions. The full document is never sent.
|
|
90
|
+
|
|
91
|
+
### Props
|
|
92
|
+
|
|
93
|
+
| Prop | Type | Default | |
|
|
94
|
+
| --- | --- | --- | --- |
|
|
95
|
+
| `markdown` | `string` | — | The document. |
|
|
96
|
+
| `classifier` | `Classify` | — | Answers judgement calls; omit for rules only. |
|
|
97
|
+
| `theme` | `Partial<ThemeChoice>` | — | Fix `palette`, `fonts` or `formality` (corner style). |
|
|
98
|
+
| `appearance` | `"auto" \| "light" \| "dark"` | `"auto"` | `auto` follows the system setting. |
|
|
99
|
+
| `resolveUrl` | `(url: string) => string` | identity | Map URLs in the markdown to loadable ones. |
|
|
100
|
+
| `controls` | `boolean` | `true` | Show the view switch and the theme, layout and chart pickers. |
|
|
101
|
+
| `editable` | `boolean` | `false` | Let the reader edit the text in the markdown view. |
|
|
102
|
+
| `onMarkdownChange` | `(markdown: string) => void` | — | Called when the reader's edits are applied. |
|
|
103
|
+
| `loadFonts` | `boolean` | `true` | Load the theme's typefaces from Google Fonts. |
|
|
104
|
+
| `settleMs` | `number` | `2500` | Longest wait for an image to report its size. |
|
|
105
|
+
| `maxWaitMs` | `number` | `8000` | Longest the loader waits for the classifier and fonts. |
|
|
106
|
+
| `className` | `string` | — | Added to the root element. |
|
|
107
|
+
| `onPlan` | `(plan, theme) => void` | — | Inspect the decisions that were made. |
|
|
108
|
+
|
|
109
|
+
A theme can also be set per document, in frontmatter: `theme: midnight`.
|
|
110
|
+
|
|
111
|
+
### Entry points
|
|
112
|
+
|
|
113
|
+
| Import | Contents | Runs |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `stunning-md` | `StunningMarkdown`, `createClassifier`, themes, and everything in `core` | In the browser |
|
|
116
|
+
| `stunning-md/core` | `parseMarkdown`, `planDocument`, table inference, `judgeDocument`, theme data | Anywhere — no React |
|
|
117
|
+
| `stunning-md/server` | `createClassifierHandler` | On the server |
|
|
118
|
+
| `stunning-md/styles.css` | All styles for the component | — |
|
|
119
|
+
|
|
120
|
+
The analysis is plain TypeScript and useful on its own:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { parseMarkdown, planDocument } from "stunning-md/core"
|
|
124
|
+
|
|
125
|
+
const plan = planDocument({ ...parseMarkdown(source), images: { "cover.jpg": { width: 2400, height: 1350 } } })
|
|
126
|
+
plan.sections.map((s) => [s.titleText, s.layout, s.reason])
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## What it does
|
|
130
|
+
|
|
131
|
+
| Content | Becomes |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| Leading `# Title`, short opening paragraphs | Hero with title and lead |
|
|
134
|
+
| Leading image, ≥ 1200 px wide and landscape | Full-bleed banner behind the title |
|
|
135
|
+
| Leading image that is small, square or an SVG | Logo above a centred title |
|
|
136
|
+
| Badge images (shields.io and similar) | A badge row in the hero |
|
|
137
|
+
| Section with very few words | "Statement": large, centred, extra room |
|
|
138
|
+
| Section with one image and some text | Side-by-side split, alternating sides |
|
|
139
|
+
| Section with one very large image and little text | Full-screen image with text over it |
|
|
140
|
+
| Section whose sub-headings are each a short blurb | Card grid |
|
|
141
|
+
| Three or more images in a row | Slideshow with captions and a lightbox |
|
|
142
|
+
| Short list (≤ 8 brief items) | Large type with drawn numerals or bullets |
|
|
143
|
+
| Long list | Ordinary body-text list |
|
|
144
|
+
| Short blockquote | Pull quote, with `— attribution` split out |
|
|
145
|
+
| `> [!NOTE]` and friends | Callouts |
|
|
146
|
+
| Table: periods × measures | Line, area or bar chart |
|
|
147
|
+
| Table: categories × one measure | Bar chart, donut (parts of a whole) or stat tiles |
|
|
148
|
+
| Table: dates × descriptions | Timeline |
|
|
149
|
+
| Table: short key–value pairs | Fact sheet |
|
|
150
|
+
| Any other table | A table, with horizontal scroll on small screens |
|
|
151
|
+
| Three or more titled sections | Sticky bar with reading progress and a contents list — pinned beside the page when it is at least 1400 px wide, in a slide-over menu otherwise |
|
|
152
|
+
|
|
153
|
+
Every chart keeps its data one click away: a small button opens the underlying table in a popover, and tables can be copied as tab-separated text for pasting into a spreadsheet.
|
|
154
|
+
|
|
155
|
+
The rest of markdown works as you would expect: GFM tables, task lists, strikethrough, autolinks and footnotes; fenced code with syntax highlighting and a copy button; `$inline$` and `$$block$$` maths; YAML frontmatter (`title`, `description`, `image`, `theme`, `author`, `date`, `category`). Raw HTML is never injected — images, links, line breaks and text are recovered from it and the rest is dropped.
|
|
156
|
+
|
|
157
|
+
A hero is only built when there is something to build it from: a title, or a leading image large or logo-like enough to carry one. A document that opens with plain paragraphs simply starts with its text.
|
|
158
|
+
|
|
159
|
+
Readers stay in control. A three-position switch in the top bar moves between the markdown text, a plain conventional rendering, and the designed page; with `editable`, the text can be changed there and is laid out afresh on switching back. A theme picker and light/dark switch sit beside it, each section has a `⋯` menu offering only the layouts its content can fill, and each chart can be switched between the forms its data supports. `controls={false}` hides all of these.
|
|
160
|
+
|
|
161
|
+
## How decisions are made
|
|
162
|
+
|
|
163
|
+
1. **Parse** — markdown to an mdast tree (`parseMarkdown`).
|
|
164
|
+
2. **Measure** — each image is loaded in the browser to learn its natural size. An image that cannot be measured is never promoted to a hero or full-screen layout.
|
|
165
|
+
3. **Plan** — `planDocument` turns tree + image sizes into a `DocumentPlan`. It is a pure function: the same input always gives the same plan, and every section records the `reason` for its layout.
|
|
166
|
+
4. **Judge** *(optional)* — `judgeDocument` asks the classifier a handful of questions and each answer is fed back into the plan as it lands:
|
|
167
|
+
- which theme suits the content — each option tells the classifier what the theme is for, its key colours and its typeface;
|
|
168
|
+
- whether a table's numbers are measurements to compare or reference values to look up;
|
|
169
|
+
- whether rows are parts of a whole (donut) or independent (bar);
|
|
170
|
+
- whether the leading image is fit to be the hero.
|
|
171
|
+
|
|
172
|
+
The document's own vocabulary counts as evidence too: when the classifier is torn between themes, keywords in the text settle it, but they cannot overturn a classifier that is sure. Without a classifier, the theme is chosen from those keywords alone.
|
|
173
|
+
|
|
174
|
+
While this happens the page shows a loader, with the document already laid out beneath it. Questions are asked top-down and all at once — nothing waits for scrolling. The loader lifts as soon as the theme and its fonts are in and no unanswered question concerns a block in the first two screens, so what the reader sees does not move; answers for tables further down are applied as they arrive. `maxWaitMs` caps the wait.
|
|
175
|
+
|
|
176
|
+
Chart forms follow a few fixed rules: one unit per axis (columns with different units or very different scales get separate charts), summary rows such as "Total" are left out of the plot, time runs left to right, and a handful of headline figures become stat tiles rather than a chart.
|
|
177
|
+
|
|
178
|
+
## Themes
|
|
179
|
+
|
|
180
|
+
There are 21 themes, each a complete look — light and dark colours, a typeface pairing and a corner style:
|
|
181
|
+
|
|
182
|
+
`paper` · `ink` · `ocean` · `forest` · `sunset` · `violet` · `terminal` · `chambers` · `academia` · `blueprint` · `midnight` · `rose` · `sand` · `citrus` · `crimson` · `slate` · `lagoon` · `plum` · `poster` · `espresso` · `console`
|
|
183
|
+
|
|
184
|
+
and 14 typeface pairings (`editorial`, `modern`, `technical`, `elegant`, `friendly`, `classic`, `scholarly`, `geometric`, `luxe`, `rounded`, `gazette`, `slab`, `poster`, `mono`), loaded from Google Fonts.
|
|
185
|
+
|
|
186
|
+
Charts follow the theme too:
|
|
187
|
+
|
|
188
|
+
- **Colours** — each theme's series colours are grown from its accent. The accent leads; every further colour is the candidate that stays furthest from those before it, at the accent's own intensity, so a muted theme gets muted charts and a vivid one vivid charts. Every palette must keep neighbouring series apart for readers with red-green colour-vision deficiency as well as full colour vision, sit inside a legible lightness band, and hold 3:1 contrast against the page — a unit test enforces this for all 21 themes in both modes.
|
|
189
|
+
- **Drawing style** — each theme names one of four chart styles: `linework` (monochrome ink with dashes and hatching, monospaced labels), `instrument` (rounded, saturated marks on a dotted grid), `soft` (gradients and depth) or `flat` (plain solid colour).
|
|
190
|
+
|
|
191
|
+
Themes are defined in `src/stunning-md/theme/themes.ts`. Each theme's `description` (what it is for) and `look` (its key colours), together with its typeface, are what the classifier reads when choosing, so adding a theme means adding one entry there. Keep those strings short: classifier latency grows with their total length.
|
|
192
|
+
|
|
193
|
+
Themes are applied as CSS variables scoped to the component, so the page around it is unaffected.
|
|
194
|
+
|
|
195
|
+
## The demo site
|
|
196
|
+
|
|
197
|
+
This repository is also the demo: a Next.js app that opens a markdown file — with its images, if you pick them together or choose the folder — and renders it.
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
git clone https://github.com/serrynaimo/stunning-md.git
|
|
201
|
+
cd stunning-md
|
|
202
|
+
npm install
|
|
203
|
+
cp .env.example .env.local # optional: add a classifier address and key
|
|
204
|
+
npm run dev
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Samples live in `public/samples/` and open directly with `?sample=annual-report`, `kyoto`, `readme`, `essay`, `after-dark` or `roastery`.
|
|
208
|
+
|
|
209
|
+
If the server has no classifier configured, the landing page offers a form for the visitor's own endpoint and key. Those are checked with one test question, kept only in that browser's `localStorage`, and sent straight from the browser to the endpoint — never through this site's server. The endpoint therefore has to allow cross-origin requests.
|
|
210
|
+
|
|
211
|
+
### Hosting it on GitHub Pages
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
npm run build:static # served from a domain root
|
|
215
|
+
NEXT_PUBLIC_BASE_PATH=/stunning-md npm run build:static # served from a sub-path
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The result is in `out/`. `.github/workflows/pages.yml` does this on every push to `main` and deploys it — enable Pages with "GitHub Actions" as the source and it works as is. A static host cannot run the classifier proxy, so the static build leaves that route out and never contains a key; visitors who want classifier judgements paste their own.
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
src/stunning-md/ the library
|
|
224
|
+
index.ts · core.ts · server.ts the package's entry points
|
|
225
|
+
parse.ts markdown → mdast, HTML clean-up, frontmatter
|
|
226
|
+
analyze/ layout planning, table inference, image probing
|
|
227
|
+
classifier.ts questions, client, applying answers
|
|
228
|
+
theme/ palettes, typefaces, chart colours and styles
|
|
229
|
+
components/ React rendering
|
|
230
|
+
stunning.css typography and layouts, scoped to .smd
|
|
231
|
+
package.css entry for the stylesheet shipped to npm
|
|
232
|
+
src/components/ui/ shadcn/ui components, bundled into the package
|
|
233
|
+
src/app/ the demo site
|
|
234
|
+
tests/ unit tests
|
|
235
|
+
scripts/ browser checks used during development
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
npm test # parsing, planning, table inference, classifier logic, theme and chart colours
|
|
240
|
+
npm run typecheck
|
|
241
|
+
npm run lint
|
|
242
|
+
npm run build # the demo site
|
|
243
|
+
npm run build:lib # the npm package, into dist/
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The demo imports the library from source and styles it with its own Tailwind setup; the npm package is built separately by `build:lib`, which bundles the components with tsup and compiles the stylesheet with the Tailwind CLI so consumers need neither.
|
|
247
|
+
|
|
248
|
+
The files in `scripts/` drive a local Chrome through the samples while the dev server is running (`SMD_URL` overrides the default `http://localhost:3000`).
|
|
249
|
+
|
|
250
|
+
### Releasing to npm
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
npm login # once
|
|
254
|
+
npm version patch # or minor / major; commits and tags
|
|
255
|
+
npm publish # runs typecheck, tests and build:lib first
|
|
256
|
+
git push --follow-tags
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`npm pack --dry-run` shows exactly what would be published: `dist/`, this README, the licence and `package.json`.
|
|
260
|
+
|
|
261
|
+
## Built with
|
|
262
|
+
|
|
263
|
+
| | |
|
|
264
|
+
| --- | --- |
|
|
265
|
+
| [Next.js](https://nextjs.org) | The demo app and its static export |
|
|
266
|
+
| [shadcn/ui](https://ui.shadcn.com) | Menus, sheets, dialogs, popovers and buttons |
|
|
267
|
+
| [Generative Charts](https://generativecharts.com) | Tables drawn as charts |
|
|
268
|
+
| [KaTeX](https://katex.org) | Typeset mathematics |
|
|
269
|
+
| [remark](https://remark.js.org) | Markdown parsed into a syntax tree |
|
|
270
|
+
| [TinyJev](https://huggingface.co/AnkitAI/TinyJev-4B) | The classifier the demo was built against |
|
|
271
|
+
|
|
272
|
+
Also [lowlight](https://github.com/wooorm/lowlight) for syntax highlighting and [Embla Carousel](https://www.embla-carousel.com) for slideshows. Sample photographs are served by [Lorem Picsum](https://picsum.photos) from Unsplash.
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
MIT
|
package/dist/core.d.mts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { Root, RootContent, PhrasingContent, Table } from 'mdast';
|
|
2
|
+
|
|
3
|
+
type Frontmatter = Record<string, unknown>;
|
|
4
|
+
type ParsedMarkdown = {
|
|
5
|
+
root: Root;
|
|
6
|
+
frontmatter: Frontmatter;
|
|
7
|
+
};
|
|
8
|
+
declare function parseMarkdown(markdown: string): ParsedMarkdown;
|
|
9
|
+
|
|
10
|
+
/** Natural size of an image, discovered by probing it in the browser. */
|
|
11
|
+
type ImageMeta = {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
};
|
|
15
|
+
type ImageRef = {
|
|
16
|
+
/** URL as written in the markdown. */
|
|
17
|
+
src: string;
|
|
18
|
+
alt: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
/** Link target when the image was wrapped in a link. */
|
|
21
|
+
href?: string;
|
|
22
|
+
meta?: ImageMeta;
|
|
23
|
+
};
|
|
24
|
+
type ColumnType = "number" | "date" | "text";
|
|
25
|
+
type TableColumn = {
|
|
26
|
+
key: string;
|
|
27
|
+
label: string;
|
|
28
|
+
type: ColumnType;
|
|
29
|
+
/** Currency prefix or unit suffix shared by the column ("$", "%", "ms"). */
|
|
30
|
+
unit?: {
|
|
31
|
+
prefix: string;
|
|
32
|
+
suffix: string;
|
|
33
|
+
};
|
|
34
|
+
align: "left" | "right" | "center";
|
|
35
|
+
};
|
|
36
|
+
type TableRow = {
|
|
37
|
+
/** Display text per column key. */
|
|
38
|
+
text: Record<string, string>;
|
|
39
|
+
/** Parsed value per column key (numbers for numeric columns, epoch ms for dates). */
|
|
40
|
+
value: Record<string, number | string | null>;
|
|
41
|
+
/** Original cells, for rich rendering in the table view. */
|
|
42
|
+
cells: PhrasingContent[][];
|
|
43
|
+
};
|
|
44
|
+
type TableModel = {
|
|
45
|
+
node: Table;
|
|
46
|
+
columns: TableColumn[];
|
|
47
|
+
rows: TableRow[];
|
|
48
|
+
};
|
|
49
|
+
type VizKind = "table" | "bar" | "line" | "area" | "donut" | "scatter" | "stats" | "timeline" | "facts";
|
|
50
|
+
type ChartSpec = {
|
|
51
|
+
/** Numeric column keys plotted in this chart; all share one unit, hence one axis. */
|
|
52
|
+
seriesKeys: string[];
|
|
53
|
+
unit?: {
|
|
54
|
+
prefix: string;
|
|
55
|
+
suffix: string;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
type VizPlan = {
|
|
59
|
+
kind: VizKind;
|
|
60
|
+
/** Column used for categories / the x axis / timeline dates. */
|
|
61
|
+
labelKey?: string;
|
|
62
|
+
/** One chart per unit group — never two scales on one plot. */
|
|
63
|
+
charts: ChartSpec[];
|
|
64
|
+
horizontal?: boolean;
|
|
65
|
+
/** Rows used for plotting (summary rows such as "Total" removed). */
|
|
66
|
+
rowIndices: number[];
|
|
67
|
+
/** Other forms that would also be valid; the classifier may pick among them. */
|
|
68
|
+
candidates: VizKind[];
|
|
69
|
+
/** Why this form was chosen — surfaced for debugging and tests. */
|
|
70
|
+
reason: string;
|
|
71
|
+
};
|
|
72
|
+
type ListVariant = "feature" | "body" | "checklist";
|
|
73
|
+
type QuoteVariant = "pull" | "aside" | "callout";
|
|
74
|
+
type MediaVariant = "single" | "pair" | "slideshow";
|
|
75
|
+
type Block = {
|
|
76
|
+
kind: "content";
|
|
77
|
+
node: RootContent;
|
|
78
|
+
} | {
|
|
79
|
+
kind: "heading";
|
|
80
|
+
depth: number;
|
|
81
|
+
id: string;
|
|
82
|
+
children: PhrasingContent[];
|
|
83
|
+
} | {
|
|
84
|
+
kind: "list";
|
|
85
|
+
node: Extract<RootContent, {
|
|
86
|
+
type: "list";
|
|
87
|
+
}>;
|
|
88
|
+
variant: ListVariant;
|
|
89
|
+
columns: 1 | 2;
|
|
90
|
+
} | {
|
|
91
|
+
kind: "quote";
|
|
92
|
+
variant: QuoteVariant;
|
|
93
|
+
children: RootContent[];
|
|
94
|
+
attribution?: string;
|
|
95
|
+
calloutType?: string;
|
|
96
|
+
} | {
|
|
97
|
+
kind: "code";
|
|
98
|
+
lang?: string;
|
|
99
|
+
value: string;
|
|
100
|
+
} | {
|
|
101
|
+
kind: "media";
|
|
102
|
+
variant: MediaVariant;
|
|
103
|
+
images: ImageRef[];
|
|
104
|
+
} | {
|
|
105
|
+
kind: "data";
|
|
106
|
+
id: string;
|
|
107
|
+
table: TableModel;
|
|
108
|
+
viz: VizPlan;
|
|
109
|
+
} | {
|
|
110
|
+
kind: "cards";
|
|
111
|
+
items: {
|
|
112
|
+
id: string;
|
|
113
|
+
title: PhrasingContent[];
|
|
114
|
+
blocks: Block[];
|
|
115
|
+
}[];
|
|
116
|
+
} | {
|
|
117
|
+
kind: "rule";
|
|
118
|
+
};
|
|
119
|
+
type SectionLayout = "prose" | "statement" | "split" | "fullbleed" | "cards" | "showcase" | "quote";
|
|
120
|
+
type SectionTone = "plain" | "tint" | "invert";
|
|
121
|
+
type Section = {
|
|
122
|
+
id: string;
|
|
123
|
+
/** Empty for the untitled introduction. */
|
|
124
|
+
title: PhrasingContent[];
|
|
125
|
+
titleText: string;
|
|
126
|
+
layout: SectionLayout;
|
|
127
|
+
tone: SectionTone;
|
|
128
|
+
blocks: Block[];
|
|
129
|
+
/** Image pulled out of the flow for split / fullbleed layouts. */
|
|
130
|
+
feature?: ImageRef;
|
|
131
|
+
/** Which side the feature image sits on in a split layout. */
|
|
132
|
+
flip?: boolean;
|
|
133
|
+
words: number;
|
|
134
|
+
reason: string;
|
|
135
|
+
/** Every layout this section's content can fill, the chosen one included. */
|
|
136
|
+
alternatives: SectionLayout[];
|
|
137
|
+
};
|
|
138
|
+
type HeroVariant = "banner" | "logo" | "figure" | "plain";
|
|
139
|
+
type Hero = {
|
|
140
|
+
variant: HeroVariant;
|
|
141
|
+
title: PhrasingContent[];
|
|
142
|
+
titleText: string;
|
|
143
|
+
lead: PhrasingContent[][];
|
|
144
|
+
image?: ImageRef;
|
|
145
|
+
badges: ImageRef[];
|
|
146
|
+
eyebrow?: string;
|
|
147
|
+
};
|
|
148
|
+
type TocEntry = {
|
|
149
|
+
id: string;
|
|
150
|
+
text: string;
|
|
151
|
+
depth: 0 | 1;
|
|
152
|
+
};
|
|
153
|
+
type DocumentPlan = {
|
|
154
|
+
hero: Hero;
|
|
155
|
+
sections: Section[];
|
|
156
|
+
toc: TocEntry[];
|
|
157
|
+
showToc: boolean;
|
|
158
|
+
words: number;
|
|
159
|
+
/** Minutes, at 220 wpm. */
|
|
160
|
+
readingTime: number;
|
|
161
|
+
};
|
|
162
|
+
type PaletteId = "paper" | "ink" | "ocean" | "forest" | "sunset" | "violet" | "terminal" | "chambers" | "academia" | "blueprint" | "midnight" | "rose" | "sand" | "citrus" | "crimson" | "slate" | "lagoon" | "plum" | "poster" | "espresso" | "console";
|
|
163
|
+
type FontPairingId = "editorial" | "modern" | "technical" | "elegant" | "friendly" | "classic" | "scholarly" | "geometric" | "luxe" | "rounded" | "gazette" | "slab" | "poster" | "mono";
|
|
164
|
+
type Appearance = "light" | "dark";
|
|
165
|
+
type ThemeChoice = {
|
|
166
|
+
palette: PaletteId;
|
|
167
|
+
fonts: FontPairingId;
|
|
168
|
+
/** 0 = soft and rounded … 1 = sharp and formal. Sets the corner radius. */
|
|
169
|
+
formality: number;
|
|
170
|
+
};
|
|
171
|
+
/** Judgement calls that structure alone cannot settle; every field is optional. */
|
|
172
|
+
type Judgements = {
|
|
173
|
+
theme?: Partial<ThemeChoice>;
|
|
174
|
+
/** Keyed by data block id. */
|
|
175
|
+
viz?: Record<string, VizKind>;
|
|
176
|
+
/** Keyed by section id; ignored unless the layout is among the section's alternatives. */
|
|
177
|
+
layouts?: Record<string, SectionLayout>;
|
|
178
|
+
/** Whether the leading image is fit to be the page hero. */
|
|
179
|
+
heroImageUsable?: boolean;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
type ImageMetaMap = Record<string, ImageMeta | null | undefined>;
|
|
183
|
+
type PlanInput = {
|
|
184
|
+
root: Root;
|
|
185
|
+
frontmatter?: Frontmatter;
|
|
186
|
+
/** Natural image sizes keyed by the URL written in the markdown. */
|
|
187
|
+
images?: ImageMetaMap;
|
|
188
|
+
judgements?: Judgements;
|
|
189
|
+
};
|
|
190
|
+
/** Every image URL in the document, for size probing. */
|
|
191
|
+
declare function collectImageUrls(root: Root): string[];
|
|
192
|
+
/**
|
|
193
|
+
* Turns a parsed document into a layout plan. Pure and deterministic: the same
|
|
194
|
+
* tree, image sizes and judgements always yield the same plan.
|
|
195
|
+
*/
|
|
196
|
+
declare function planDocument(input: PlanInput): DocumentPlan;
|
|
197
|
+
|
|
198
|
+
type Unit = {
|
|
199
|
+
prefix: string;
|
|
200
|
+
suffix: string;
|
|
201
|
+
};
|
|
202
|
+
type ParsedNumber = {
|
|
203
|
+
value: number;
|
|
204
|
+
} & Unit;
|
|
205
|
+
declare function parseNumber(raw: string): ParsedNumber | null;
|
|
206
|
+
/** Parses the date shapes that show up in tables. Returns UTC epoch milliseconds. */
|
|
207
|
+
declare function parseDate(raw: string): number | null;
|
|
208
|
+
declare function buildTableModel(node: Table): TableModel;
|
|
209
|
+
/** Chooses how a table is best shown, from its shape and the types of its columns. */
|
|
210
|
+
declare function planViz(model: TableModel): VizPlan;
|
|
211
|
+
/** Re-plans a table as a specific form, when that form is one of its candidates. */
|
|
212
|
+
declare function withVizKind(plan: VizPlan, model: TableModel, kind: VizKind): VizPlan;
|
|
213
|
+
/** Compact, unit-aware number formatting for axes, tooltips and stat tiles. */
|
|
214
|
+
declare function formatValue(value: number, unit?: Unit): string;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Question shapes of a "jev"-compatible classifier endpoint: the request carries
|
|
218
|
+
* a `state` (the text to judge) and named questions, each answered independently.
|
|
219
|
+
*/
|
|
220
|
+
type ClassifierQuestion = {
|
|
221
|
+
type: "choice";
|
|
222
|
+
criteria: Record<string, string>;
|
|
223
|
+
} | {
|
|
224
|
+
type: "noul";
|
|
225
|
+
criteria: {
|
|
226
|
+
true: string;
|
|
227
|
+
false: string;
|
|
228
|
+
};
|
|
229
|
+
} | {
|
|
230
|
+
type: "score";
|
|
231
|
+
criteria: string[];
|
|
232
|
+
};
|
|
233
|
+
type ClassifierRequest = {
|
|
234
|
+
state: string;
|
|
235
|
+
questions: Record<string, ClassifierQuestion>;
|
|
236
|
+
};
|
|
237
|
+
type ClassifierAnswer = {
|
|
238
|
+
type: "choice";
|
|
239
|
+
choice: string;
|
|
240
|
+
confidence: number;
|
|
241
|
+
probabilities?: Record<string, number>;
|
|
242
|
+
} | {
|
|
243
|
+
type: "noul";
|
|
244
|
+
noul: number;
|
|
245
|
+
} | {
|
|
246
|
+
type: "score";
|
|
247
|
+
score: number;
|
|
248
|
+
confidence?: number;
|
|
249
|
+
};
|
|
250
|
+
type ClassifierAnswers = Record<string, ClassifierAnswer>;
|
|
251
|
+
/** Anything that can answer a classifier request — usually a call to your own proxy route. */
|
|
252
|
+
type Classify = (request: ClassifierRequest, signal?: AbortSignal) => Promise<ClassifierAnswers>;
|
|
253
|
+
/**
|
|
254
|
+
* A classifier that posts to `endpoint`. Point it at a route on your own server
|
|
255
|
+
* that adds the API key (see `createClassifierHandler` in `stunning-md/server`) —
|
|
256
|
+
* a key placed in browser code is public.
|
|
257
|
+
*/
|
|
258
|
+
declare function createClassifier(options: {
|
|
259
|
+
endpoint: string;
|
|
260
|
+
headers?: Record<string, string>;
|
|
261
|
+
}): Classify;
|
|
262
|
+
/**
|
|
263
|
+
* A compact summary of the document: enough for tone and topic, small enough to
|
|
264
|
+
* be cheap. `prose` should be running text only — code and table cells mislead.
|
|
265
|
+
*/
|
|
266
|
+
declare function documentDigest(plan: DocumentPlan, prose: string): string;
|
|
267
|
+
type JudgeOptions = {
|
|
268
|
+
/** Minimum score before the classifier's theme is accepted over the keyword guess. */
|
|
269
|
+
confidence?: number;
|
|
270
|
+
/** The theme the document's own keywords point to, if any (see `matchTheme`). */
|
|
271
|
+
themeHint?: PaletteId;
|
|
272
|
+
/** Requests in flight at once. Default 3. */
|
|
273
|
+
concurrency?: number;
|
|
274
|
+
/** Upper bound on tables sent for a form judgement. */
|
|
275
|
+
maxTables?: number;
|
|
276
|
+
signal?: AbortSignal;
|
|
277
|
+
/**
|
|
278
|
+
* Called as each answer lands, with the judgements so far and what is still
|
|
279
|
+
* outstanding: `"theme"`, `"hero"`, or the id of a data block.
|
|
280
|
+
*/
|
|
281
|
+
onProgress?: (progress: JudgeProgress) => void;
|
|
282
|
+
};
|
|
283
|
+
type JudgeProgress = {
|
|
284
|
+
judgements: Judgements;
|
|
285
|
+
pending: string[];
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Asks the classifier the questions structure cannot answer: which theme —
|
|
289
|
+
* colours and typefaces together — suits the content, whether a table's numbers
|
|
290
|
+
* are there to be compared or just looked up, and whether the leading image
|
|
291
|
+
* deserves to be the hero.
|
|
292
|
+
* Questions are asked top-down — theme, hero, then tables in document order —
|
|
293
|
+
* so the top of the page settles first. A failed request simply leaves its
|
|
294
|
+
* judgement unset.
|
|
295
|
+
*/
|
|
296
|
+
declare function judgeDocument(plan: DocumentPlan, prose: string, classify: Classify, options?: JudgeOptions): Promise<Judgements>;
|
|
297
|
+
|
|
298
|
+
type PaletteTokens = {
|
|
299
|
+
bg: string;
|
|
300
|
+
/** Cards, code blocks and tinted section bands. */
|
|
301
|
+
surface: string;
|
|
302
|
+
fg: string;
|
|
303
|
+
/** Secondary text. */
|
|
304
|
+
muted: string;
|
|
305
|
+
border: string;
|
|
306
|
+
accent: string;
|
|
307
|
+
accentFg: string;
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Chart drawing styles: `linework` is monochrome print-style ink with hatching,
|
|
311
|
+
* `instrument` has rounded, saturated marks on calm surfaces, `soft` adds
|
|
312
|
+
* gradients and depth, and `flat` is plain solid colour.
|
|
313
|
+
*/
|
|
314
|
+
type ChartStyle = "linework" | "instrument" | "soft" | "flat";
|
|
315
|
+
type Theme = {
|
|
316
|
+
id: PaletteId;
|
|
317
|
+
name: string;
|
|
318
|
+
/** The kinds of document this theme suits. */
|
|
319
|
+
description: string;
|
|
320
|
+
/** Its key colours in a few plain words — read by the classifier alongside the description. */
|
|
321
|
+
look: string;
|
|
322
|
+
/** Typography used unless the caller picks another. */
|
|
323
|
+
fonts: FontPairingId;
|
|
324
|
+
/** 0 = soft and rounded … 1 = sharp and formal. Sets the corner radius. */
|
|
325
|
+
formality: number;
|
|
326
|
+
/** How charts are drawn under this theme. */
|
|
327
|
+
chart: ChartStyle;
|
|
328
|
+
/** Fallback matching when no classifier is configured. */
|
|
329
|
+
keywords: RegExp;
|
|
330
|
+
light: PaletteTokens;
|
|
331
|
+
dark: PaletteTokens;
|
|
332
|
+
};
|
|
333
|
+
declare const themes: Record<PaletteId, Theme>;
|
|
334
|
+
declare const themeList: Theme[];
|
|
335
|
+
type FontPairing = {
|
|
336
|
+
id: FontPairingId;
|
|
337
|
+
name: string;
|
|
338
|
+
/** The headline typeface and its character, in a few words — read by the classifier. */
|
|
339
|
+
look: string;
|
|
340
|
+
heading: string;
|
|
341
|
+
body: string;
|
|
342
|
+
mono: string;
|
|
343
|
+
/** Google Fonts `family=` parameters. */
|
|
344
|
+
google: string[];
|
|
345
|
+
headingWeight: number;
|
|
346
|
+
/** Heading letter-spacing in em. */
|
|
347
|
+
tracking: number;
|
|
348
|
+
};
|
|
349
|
+
declare const fontPairings: Record<FontPairingId, FontPairing>;
|
|
350
|
+
declare const fontPairingList: FontPairing[];
|
|
351
|
+
/**
|
|
352
|
+
* What the classifier reads when choosing a theme: the content it suits, its
|
|
353
|
+
* key colours and its typeface, so the choice can weigh appearance as well as
|
|
354
|
+
* subject. Kept terse on purpose — classifier latency grows with every
|
|
355
|
+
* character here, across every theme.
|
|
356
|
+
*/
|
|
357
|
+
declare function describeTheme(theme: Theme): string;
|
|
358
|
+
/** The complete default choice a theme stands for. */
|
|
359
|
+
declare function themeChoice(id: PaletteId): ThemeChoice;
|
|
360
|
+
/**
|
|
361
|
+
* The theme whose vocabulary stands out in the text, or `null` when none does.
|
|
362
|
+
* This is evidence from the words themselves, independent of any classifier.
|
|
363
|
+
*/
|
|
364
|
+
declare function matchTheme(text: string, stats: {
|
|
365
|
+
codeBlocks: number;
|
|
366
|
+
tables: number;
|
|
367
|
+
}): PaletteId | null;
|
|
368
|
+
/**
|
|
369
|
+
* Picks a theme from the words in the document — the fallback when no classifier
|
|
370
|
+
* is configured, and what the loader is drawn in while the classifier decides.
|
|
371
|
+
*/
|
|
372
|
+
declare function guessTheme(text: string, stats: {
|
|
373
|
+
codeBlocks: number;
|
|
374
|
+
tables: number;
|
|
375
|
+
}): ThemeChoice;
|
|
376
|
+
|
|
377
|
+
export { type Appearance, type Block, type ChartSpec, type ClassifierAnswer, type ClassifierAnswers, type ClassifierQuestion, type ClassifierRequest, type Classify, type ColumnType, type DocumentPlan, type FontPairing, type FontPairingId, type Frontmatter, type Hero, type HeroVariant, type ImageMeta, type ImageMetaMap, type ImageRef, type JudgeOptions, type JudgeProgress, type Judgements, type ListVariant, type MediaVariant, type PaletteId, type ParsedMarkdown, type PlanInput, type QuoteVariant, type Section, type SectionLayout, type SectionTone, type TableColumn, type TableModel, type TableRow, type Theme, type ThemeChoice, type TocEntry, type VizKind, type VizPlan, buildTableModel, collectImageUrls, createClassifier, describeTheme, documentDigest, fontPairingList, fontPairings, formatValue, guessTheme, judgeDocument, matchTheme, parseDate, parseMarkdown, parseNumber, planDocument, planViz, themeChoice, themeList, themes, withVizKind };
|