stunning-md 0.0.0-stage → 0.2.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 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,368 @@
1
- # Temporary Holding Version
1
+ # stunning-md
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Markdown in. A beautifully designed website out.
4
+
5
+ [![A markdown file about a coffee roastery, rendered by stunning-md: a full-width photograph behind the title, and the contents pinned on the right](docs/roastery.jpg)](https://serrynaimo.github.io/stunning-md/?sample=roastery)
6
+
7
+ *[This markdown file](public/samples/roastery.md), rendered. [Open it live.](https://serrynaimo.github.io/stunning-md/?sample=roastery)*
8
+
9
+ `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.
10
+
11
+ The markdown can be a file, or a model's answer [as it streams in](#streaming-a-models-answer) — and with a chat model attached, the page itself [takes requests](#adding-chat-optional) and lays each answer out as it is written.
12
+
13
+ **[Try the live demo](https://serrynaimo.github.io/stunning-md/)** with your own file or one of the samples.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm i stunning-md
19
+ ```
20
+
21
+ 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.
22
+
23
+ ## Use it
24
+
25
+ Import the two stylesheets once, at the root of your app:
26
+
27
+ ```tsx
28
+ // app/layout.tsx
29
+ import "stunning-md/styles.css"
30
+ import "katex/dist/katex.min.css" // maths; installed with stunning-md
31
+ ```
32
+
33
+ Then render a document:
34
+
35
+ ```tsx
36
+ // app/page.tsx — a Next.js server component
37
+ import { readFile } from "node:fs/promises"
38
+ import { StunningMarkdown } from "stunning-md"
39
+
40
+ export default async function Page() {
41
+ const markdown = await readFile("content/report.md", "utf8")
42
+ return <StunningMarkdown markdown={markdown} />
43
+ }
44
+ ```
45
+
46
+ 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.
47
+
48
+ 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.
49
+
50
+ ### Images with relative paths
51
+
52
+ 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:
53
+
54
+ ```tsx
55
+ "use client"
56
+ import { StunningMarkdown } from "stunning-md"
57
+
58
+ const resolveUrl = (url: string) => (/^(https?:|data:|blob:|\/)/.test(url) ? url : `/content/${url}`)
59
+
60
+ export function Document({ markdown }: { markdown: string }) {
61
+ return <StunningMarkdown markdown={markdown} resolveUrl={resolveUrl} />
62
+ }
63
+ ```
64
+
65
+ 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.
66
+
67
+ ### Adding a classifier (optional)
68
+
69
+ 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:
70
+
71
+ ```ts
72
+ // app/api/classify/route.ts
73
+ import { createClassifierHandler } from "stunning-md/server"
74
+
75
+ export const POST = createClassifierHandler({
76
+ url: process.env.STUNNING_MD_CLASSIFIER_URL,
77
+ apiKey: process.env.STUNNING_MD_CLASSIFIER_KEY,
78
+ })
79
+ ```
80
+
81
+ ```tsx
82
+ // app/document.tsx
83
+ "use client"
84
+ import { StunningMarkdown, createClassifier } from "stunning-md"
85
+
86
+ const classifier = createClassifier({ endpoint: "/api/classify" })
87
+
88
+ export function Document({ markdown }: { markdown: string }) {
89
+ return <StunningMarkdown markdown={markdown} classifier={classifier} />
90
+ }
91
+ ```
92
+
93
+ 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.
94
+
95
+ 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. With chat (below), the reader's message and single paragraphs of the model's reply are sent as well — up to a few hundred characters each — to tell conversation from content.
96
+
97
+ ### Streaming a model's answer
98
+
99
+ In an AI interface the markdown arrives a little at a time. Pass what you have so far on every update and say that more is coming:
100
+
101
+ ```tsx
102
+ "use client"
103
+ import { StunningMarkdown } from "stunning-md"
104
+
105
+ export function Answer({ text, done }: { text: string; done: boolean }) {
106
+ return <StunningMarkdown markdown={text} streaming={!done} classifier={classifier} />
107
+ }
108
+ ```
109
+
110
+ `text` is whatever your stream has produced so far — from the AI SDK's `useChat`, a `fetch` reader, anything. While `streaming` is set:
111
+
112
+ - the page is laid out as the text arrives, **a section at a time**: a section appears when the next one starts, so its layout is decided once and never shifts under the reader. The opening — title and first paragraphs — appears block by block;
113
+ - nothing half-written is shown: not the line in progress, not an open code fence or table, not unfinished frontmatter. A line under the page says which section is being written;
114
+ - the theme is chosen **once**, from the first few hundred characters, and kept. That is less to go on than a whole document, so the choice can differ from the one the finished file would get — set `theme` (or `theme:` in frontmatter) if the look should be fixed;
115
+ - there is no full-page loader, and updates do not reset the page. A text that starts over — a new answer in the same component — does.
116
+
117
+ When `streaming` goes back to `false`, the rest of the text is laid out and the page is final.
118
+
119
+ To do the same outside the component, `settledMarkdown(text)` returns the part of a growing text that is ready, and the heading being written. If your model wraps its answer in remarks ("Sure, here is…"), `sortReply` separates those from the content as it streams — that is what the chat below is built on.
120
+
121
+ It helps to tell the model what it is writing for. `CHAT_INSTRUCTIONS` is a short system prompt that does: answer in markdown with a title and sections, keep remarks to the reader apart from the content, and — since the page draws charts itself — write data as a plain markdown table rather than describing or drawing a chart.
122
+
123
+ ### Adding chat (optional)
124
+
125
+ Give the component a chat function and the page takes requests. A floating input at the bottom sends them to any OpenAI-compatible chat model; each answer is laid out below as its own designed section of the page, in a theme chosen for it, while the conversation itself lives in the sidebar.
126
+
127
+ ```ts
128
+ // app/api/chat/route.ts
129
+ import { createChatHandler } from "stunning-md/server"
130
+
131
+ export const POST = createChatHandler({
132
+ url: process.env.STUNNING_MD_CHAT_URL, // …/v1, or the full …/chat/completions address
133
+ model: process.env.STUNNING_MD_CHAT_MODEL,
134
+ apiKey: process.env.STUNNING_MD_CHAT_KEY, // optional — a local model needs none
135
+ })
136
+ ```
137
+
138
+ ```tsx
139
+ "use client"
140
+ import { StunningMarkdown, createChat, createClassifier } from "stunning-md"
141
+
142
+ const chat = createChat({ endpoint: "/api/chat" })
143
+ const classifier = createClassifier({ endpoint: "/api/classify" })
144
+
145
+ export function Document({ markdown }: { markdown: string }) {
146
+ return <StunningMarkdown markdown={markdown} chat={chat} classifier={classifier} />
147
+ }
148
+ ```
149
+
150
+ `markdown` may be an empty string: the page then starts blank and fills as you ask.
151
+
152
+ A model's reply is sorted as it streams, block by block, into two kinds of text:
153
+
154
+ - **Content** — the thing that was asked for — is rendered on the page by the usual rules. It appears a section at a time, as each section is completed, so a section's layout is decided once and does not shift. Turns are set apart by a band of bare page, black or white with the mode.
155
+ - **Commentary** — the model talking about its answer ("Sure, here is…", "Let me know if…") — is shown briefly above the input, then fades. It stays in the sidebar, where the whole conversation is kept in order: your messages, the model's remarks, and, in place of each answer, a short list of the headings it put on the page, which jump to them.
156
+
157
+ Not every message asks for a page. Before the reply arrives, the message itself is judged (`wantsContent`): a greeting, thanks or small talk gets its reply in the conversation and the page makes no room for it — unless the reply turns out to carry content after all. The classifier is asked this; without one, only messages that are plainly small talk count.
158
+
159
+ Likewise, a reply that is not an answer — the model does not know, cannot help, or asks a question back — puts nothing on the page: all of it is commentary, the place made for the turn is taken away again, and the reader is returned to where they were. The classifier is asked this of a reply's first paragraph, with the request beside it; without a classifier, the paragraph's opening words decide.
160
+
161
+ The conversation sits beside the page on a wide screen and in a sheet on a narrower one. It stays shut until the first reply starts to arrive, then opens by itself where there is room for it; after that the reader's own choice stands. The input is centred on the whole window and floats above the conversation, sheet included, so you can keep writing with the conversation in view — and while it is in view, remarks are not shown a second time above the input.
162
+
163
+ Each answer chooses its theme once, from its opening and the request, and keeps it; a new answer starts with a full window to itself, so it can be brought to the top before it is written. Until that choice is made the page keeps the chat's own plain, neutral look — the one its sidebar and input wear whatever the turns are wearing. While nothing has come back yet, the new turn shows the request itself beside a spinner; it fades as the answer starts, and the spinner stays until the turn is done. On a page with nothing on it yet, the request is shown this way at once, whatever kind of message it turns out to be.
164
+
165
+ Headings, lists, tables, code and images are always content. A plain paragraph is judged by where it sits and how it reads: remarks come at the start or the end of a reply and usually announce themselves. The classifier is asked about the unclear ones — on its own it is not a reliable judge of this, so it never overrules both position and wording. Without a classifier, the first paragraph of a reply is taken as commentary, and so is the last.
166
+
167
+ The open document and the conversation so far are sent to the chat model with each request, after `CHAT_INSTRUCTIONS` as the system prompt.
168
+
169
+ ### Props
170
+
171
+ | Prop | Type | Default | |
172
+ | --- | --- | --- | --- |
173
+ | `markdown` | `string` | — | The document. |
174
+ | `streaming` | `boolean` | `false` | The text is still being written; lay it out as it grows. |
175
+ | `classifier` | `Classify` | — | Answers judgement calls; omit for rules only. |
176
+ | `chat` | `Chat` | — | Lets the reader ask for content; answers are laid out on the page. |
177
+ | `theme` | `Partial<ThemeChoice>` | — | Fix `palette`, `fonts` or `formality` (corner style). |
178
+ | `autoTheme` | `boolean` | `true` | Choose a theme to suit each document and answer. `false` stays on one theme. |
179
+ | `appearance` | `"auto" \| "light" \| "dark"` | `"auto"` | `auto` follows the system setting. |
180
+ | `resolveUrl` | `(url: string) => string` | identity | Map URLs in the markdown to loadable ones. |
181
+ | `controls` | `boolean` | `true` | Show the view switch and the theme, layout and chart pickers. |
182
+ | `editable` | `boolean` | `false` | Let the reader edit the text in the markdown view. |
183
+ | `onMarkdownChange` | `(markdown: string) => void` | — | Called when the reader's edits are applied. |
184
+ | `chatAccessory` | `ReactNode` | — | A button or link of your own, shown as a round button left of the chat input. |
185
+ | `loadFonts` | `boolean` | `true` | Load the theme's typefaces from Google Fonts. |
186
+ | `settleMs` | `number` | `2500` | Longest wait for an image to report its size. |
187
+ | `maxWaitMs` | `number` | `8000` | Longest the loader waits for the classifier and fonts. |
188
+ | `className` | `string` | — | Added to the root element. |
189
+ | `onPlan` | `(plan, theme) => void` | — | Inspect the decisions that were made. |
190
+
191
+ A theme can also be set per document, in frontmatter: `theme: midnight`.
192
+
193
+ To keep your own look throughout, switch the choosing off: `<StunningMarkdown markdown={text} autoTheme={false} theme={{ palette: "ocean" }} />` wears that one theme for the document and for everything a chat or a stream adds to it, and asks the classifier nothing about themes. Without a `palette` it stays on `paper`. Readers have the same switch — "Match the content", at the top of the theme menu: with it off, the theme they are looking at stays, and any theme they pick applies to the whole page.
194
+
195
+ ### Entry points
196
+
197
+ | Import | Contents | Runs |
198
+ | --- | --- | --- |
199
+ | `stunning-md` | `StunningMarkdown`, `createClassifier`, `createChat`, themes, and everything in `core` | In the browser |
200
+ | `stunning-md/core` | `parseMarkdown`, `planDocument`, table inference, `judgeDocument`, `settledMarkdown`, `sortReply`, `wantsContent`, `CHAT_INSTRUCTIONS`, theme data | Anywhere — no React |
201
+ | `stunning-md/server` | `createClassifierHandler`, `createChatHandler` | On the server |
202
+ | `stunning-md/styles.css` | All styles for the component | — |
203
+
204
+ The analysis is plain TypeScript and useful on its own:
205
+
206
+ ```ts
207
+ import { parseMarkdown, planDocument } from "stunning-md/core"
208
+
209
+ const plan = planDocument({ ...parseMarkdown(source), images: { "cover.jpg": { width: 2400, height: 1350 } } })
210
+ plan.sections.map((s) => [s.titleText, s.layout, s.reason])
211
+ ```
212
+
213
+ ## What it does
214
+
215
+ | Content | Becomes |
216
+ | --- | --- |
217
+ | Leading `# Title`, short opening paragraphs | Hero with title and lead — on the page, or as a cover in the theme's colour |
218
+ | Leading image, ≥ 1200 px wide and landscape | Full-bleed banner behind the title |
219
+ | Leading image that is small, square or an SVG | Logo above a centred title |
220
+ | Badge images (shields.io and similar) | A badge row in the hero |
221
+ | Section with very few words | "Statement": large, centred, extra room |
222
+ | Section with one image and some text | Side-by-side split, alternating sides |
223
+ | Section with one very large image and little text | Full-screen image with text over it |
224
+ | Section whose sub-headings are each a short blurb | Card grid |
225
+ | Three or more images in a row | Slideshow with captions and a lightbox |
226
+ | Short list (≤ 8 brief items) | Large type with drawn numerals or bullets |
227
+ | Long list | Ordinary body-text list |
228
+ | Short blockquote | Pull quote, with `— attribution` split out |
229
+ | `> [!NOTE]` and friends | Callouts |
230
+ | Table: periods × measures | Line, area or bar chart |
231
+ | Table: categories × one measure | Bar chart, donut (parts of a whole) or stat tiles |
232
+ | Table: categories with long names × one measure | Ranked bars, each name on its own line above its bar |
233
+ | Table: dates × descriptions — or descriptions beside a column of years in order | Timeline |
234
+ | Table: short key–value pairs | Fact sheet |
235
+ | Any other table | A table, with horizontal scroll on small screens |
236
+ | 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 |
237
+
238
+ 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.
239
+
240
+ 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.
241
+
242
+ 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.
243
+
244
+ 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 — arranged by subject, with a switch for whether themes should match the content at all — and a 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.
245
+
246
+ ## How decisions are made
247
+
248
+ 1. **Parse** — markdown to an mdast tree (`parseMarkdown`).
249
+ 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.
250
+ 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.
251
+ 4. **Judge** *(optional)* — `judgeDocument` asks the classifier a handful of questions and each answer is fed back into the plan as it lands:
252
+ - which theme suits the content — each option tells the classifier the subjects the theme is for;
253
+ - whether a table's numbers are measurements to compare or reference values to look up;
254
+ - whether rows are parts of a whole (donut) or independent (bar);
255
+ - whether the leading image is fit to be the hero.
256
+
257
+ 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.
258
+
259
+ 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.
260
+
261
+ 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.
262
+
263
+ ## Themes
264
+
265
+ There are 21 themes, each a complete look — light and dark colours, a typeface pairing and a corner style:
266
+
267
+ `paper` · `ink` · `ocean` · `forest` · `sunset` · `violet` · `terminal` · `chambers` · `academia` · `blueprint` · `midnight` · `rose` · `sand` · `citrus` · `crimson` · `slate` · `lagoon` · `plum` · `poster` · `espresso` · `console`
268
+
269
+ The themes are meant to look unlike one another. Pages are coloured rather than tinted — a sunny yellow, an aqua, a blueprint blue, a wine red at night — with cards a clear step lighter or darker than the page. And a hero led by its text opens one of three ways, set per theme as `hero`: `wash` (a soft glow of the accent on the page), `block` (a cover in the accent colour) or `ink` (a cover in the theme's darkest tone, the page's colours reversed). A hero with a photograph or a logo keeps the page as it is.
270
+
271
+ About half the themes also carry a second colour (`highlight`) — amber beside corporate blue, lime beside purple, coral beside teal. It runs through the page in small things, so that it never turns up just once: a stripe under the cover, a short rule over every section heading, the reading progress in the bar. Quotations are set in it — a band across the page for one that is a section of its own, a block for one inside an article. Themes without a second colour reverse into ink for those instead.
272
+
273
+ There are also 14 typeface pairings (`editorial`, `modern`, `technical`, `elegant`, `friendly`, `classic`, `scholarly`, `geometric`, `luxe`, `rounded`, `gazette`, `slab`, `poster`, `mono`), loaded from Google Fonts.
274
+
275
+ Charts follow the theme too:
276
+
277
+ - **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.
278
+ - **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).
279
+
280
+ Themes are defined in `src/stunning-md/theme/themes.ts`, and fall into six families by subject — writing, business and official, technology, places and lifestyle, nature and health, culture and play — which is how the picker arranges them. A theme's `description`, the subjects it suits, is all the classifier reads when choosing, so adding a theme means adding one entry there. Name subjects, not moods or colours, and keep it short: on a set of documents of known subject, subjects alone picked a fitting theme more often than subjects with colours and typefaces beside them, in half the time — classifier latency grows with the total length of these strings.
281
+
282
+ Themes are applied as CSS variables scoped to the component, so the page around it is unaffected.
283
+
284
+ ## The demo site
285
+
286
+ 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.
287
+
288
+ ```bash
289
+ git clone https://github.com/serrynaimo/stunning-md.git
290
+ cd stunning-md
291
+ npm install
292
+ cp .env.example .env.local # optional: add a classifier address and key
293
+ npm run dev
294
+ ```
295
+
296
+ Samples live in `public/samples/` and open directly with `?sample=annual-report`, `kyoto`, `readme`, `essay`, `after-dark` or `roastery`. Add `&stream` — `?sample=roastery&stream` — to have the sample played out a little at a time, as a model would write it, and see the `streaming` prop at work.
297
+
298
+ To try the chat, add `STUNNING_MD_CHAT_URL` and `STUNNING_MD_CHAT_MODEL` (and `STUNNING_MD_CHAT_KEY` if the provider needs one) to `.env.local`. Without a model to hand, `node scripts/mock-chat.mjs` runs a stand-in that streams canned answers at `http://localhost:3490/v1/chat/completions`. With chat available, the landing page also offers to start from a blank page.
299
+
300
+ If the server has no classifier or chat model configured, the landing page offers a form for the visitor's own — an endpoint and key for the classifier; an address, model name and optional key for chat. 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.
301
+
302
+ ### Hosting it on GitHub Pages
303
+
304
+ ```bash
305
+ npm run build:static # served from a domain root
306
+ NEXT_PUBLIC_BASE_PATH=/stunning-md npm run build:static # served from a sub-path
307
+ ```
308
+
309
+ 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.
310
+
311
+ ## Development
312
+
313
+ ```
314
+ src/stunning-md/ the library
315
+ index.ts · core.ts · server.ts the package's entry points
316
+ parse.ts markdown → mdast, HTML clean-up, frontmatter
317
+ analyze/ layout planning, table inference, image probing
318
+ classifier.ts questions, client, applying answers
319
+ chat.ts chat client, and sorting a reply into commentary and content
320
+ theme/ palettes, typefaces, chart colours and styles
321
+ components/ React rendering
322
+ stunning.css typography and layouts, scoped to .smd
323
+ package.css entry for the stylesheet shipped to npm
324
+ src/components/ui/ shadcn/ui components, bundled into the package
325
+ src/app/ the demo site
326
+ tests/ unit tests
327
+ scripts/ browser checks used during development
328
+ ```
329
+
330
+ ```bash
331
+ npm test # parsing, planning, table inference, classifier logic, chat and streaming, theme and chart colours
332
+ npm run typecheck
333
+ npm run lint
334
+ npm run build # the demo site
335
+ npm run build:lib # the npm package, into dist/
336
+ ```
337
+
338
+ 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.
339
+
340
+ 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`).
341
+
342
+ ### Releasing to npm
343
+
344
+ ```bash
345
+ npm login # once
346
+ npm version patch # or minor / major; commits and tags
347
+ npm publish # runs typecheck, tests and build:lib first
348
+ git push --follow-tags
349
+ ```
350
+
351
+ `npm pack --dry-run` shows exactly what would be published: `dist/`, this README, the licence and `package.json`. What changed in each version is in [CHANGELOG.md](CHANGELOG.md).
352
+
353
+ ## Built with
354
+
355
+ | | |
356
+ | --- | --- |
357
+ | [Next.js](https://nextjs.org) | The demo app and its static export |
358
+ | [shadcn/ui](https://ui.shadcn.com) | Menus, sheets, dialogs, popovers and buttons |
359
+ | [Generative Charts](https://generativecharts.com) | Tables drawn as charts |
360
+ | [KaTeX](https://katex.org) | Typeset mathematics |
361
+ | [remark](https://remark.js.org) | Markdown parsed into a syntax tree |
362
+ | [TinyJev](https://huggingface.co/AnkitAI/TinyJev-4B) | The classifier the demo was built against |
363
+
364
+ 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.
365
+
366
+ ## License
367
+
368
+ MIT