commit
af417c5d7d
17 changed files with 3443 additions and 0 deletions
@ -0,0 +1,75 @@ |
|||||||
|
# gc-parser-asciidoc |
||||||
|
|
||||||
|
Lightweight AsciiDoc parser (no asciidoctor dependency) and publication helpers for Nostr. Accepts Nostr events as JSON and returns HTML. |
||||||
|
|
||||||
|
- **Single events (30818, 30041)**: pass one event JSON → get HTML. |
||||||
|
- **Publications (30040)**: pass one 30040 index event + section event JSONs → get one HTML page. |
||||||
|
|
||||||
|
**Size**: ~100 KB built output, zero runtime dependencies — about 80× smaller than `@asciidoctor/core` (~8.5 MB). |
||||||
|
|
||||||
|
## Features |
||||||
|
|
||||||
|
- **Headings** `=` … `======` with optional TOC |
||||||
|
- **Nostr links** — full bech32 (`naddr1`, `npub1`, `nevent1`, `nprofile1`, `note1`) and `nostr:...` in text become links |
||||||
|
- **#hashtags** — rendered as `<span class="hashtag">#tag</span>`. Style with a different color and no underline in your CSS, e.g. `.hashtag { color: var(--tag-color); text-decoration: none; }` |
||||||
|
- **Wikilinks** `[[id]]`, `[[id|label]]` |
||||||
|
- **URLs** — bare `https://...` and `link:url[text]`; backticked URLs stay plaintext |
||||||
|
- **Inline** — *bold*, _italic_, `code`, ~~strikethrough~~, ~sub~, ^sup^ |
||||||
|
- **Lists** — nested `*` / `**`, ordered `.` / `..`, mixed |
||||||
|
|
||||||
|
## Usage |
||||||
|
|
||||||
|
All APIs accept Nostr events as JSON: `{ kind, content, tags, pubkey?, id? }`. |
||||||
|
|
||||||
|
### Single events (30818, 30041) |
||||||
|
|
||||||
|
Pass one event (e.g. from a relay) and get HTML: |
||||||
|
|
||||||
|
```ts |
||||||
|
import { renderEvent } from 'gc-parser-asciidoc'; |
||||||
|
|
||||||
|
const event = { kind: 30041, content: '= Section\n\nBody.', tags: [['title', 'Section']], pubkey: '...' }; |
||||||
|
const html = renderEvent(event, { |
||||||
|
wikilinkUrl: '/events?d={dtag}', |
||||||
|
nostrBaseUrl: 'https://app.example.com/', |
||||||
|
hashtagClass: 'hashtag', |
||||||
|
}); |
||||||
|
``` |
||||||
|
|
||||||
|
For raw content (no event object), use `renderEventContent(content, options)` or `parseAsciiDoc(content, options)`. |
||||||
|
|
||||||
|
### Publications (kind 30040) |
||||||
|
|
||||||
|
Pass the 30040 index event and an array of section events (30041, 30818); get one HTML page: |
||||||
|
|
||||||
|
```ts |
||||||
|
import { renderPublicationFromEvents } from 'gc-parser-asciidoc'; |
||||||
|
|
||||||
|
const indexEvent = { kind: 30040, content: '', tags: [['title', 'My Book'], ['d', 'my-book'], ['a', '30041:...:ch1'], ['a', '30041:...:ch2']], pubkey: '...' }; |
||||||
|
const sectionEvents = [ |
||||||
|
{ kind: 30041, content: '...', tags: [['title', 'Chapter 1'], ['d', 'ch1']], pubkey: '...' }, |
||||||
|
{ kind: 30041, content: '...', tags: [['title', 'Chapter 2'], ['d', 'ch2']], pubkey: '...' }, |
||||||
|
]; |
||||||
|
|
||||||
|
const { html, tableOfContents, asciidoc } = renderPublicationFromEvents(indexEvent, sectionEvents, { toc: true }); |
||||||
|
``` |
||||||
|
|
||||||
|
If you already have structured data (not raw events), use `renderPublication({ index, sections }, options)`. Helpers: `toPublicationIndexLike(event)` to get `PublicationIndexLike` from a 30040 event; `buildPublicationAsciiDoc(input)` for the combined AsciiDoc string. |
||||||
|
|
||||||
|
## Integration tests |
||||||
|
|
||||||
|
Integration tests render events from **fixture files** in `integration-fixtures/` (no relay required). They are skipped unless `RUN_INTEGRATION=1`: |
||||||
|
|
||||||
|
```bash |
||||||
|
RUN_INTEGRATION=1 npm run test:integration |
||||||
|
``` |
||||||
|
|
||||||
|
Rendered HTML is written to `integration-output/single-event.html` and `integration-output/publication.html` for inspection in a browser. |
||||||
|
|
||||||
|
To **download events and save/refresh the fixtures** (fetch from `wss://thecitadel.nostr1.com` and overwrite `integration-fixtures/*.json`): |
||||||
|
|
||||||
|
```bash |
||||||
|
npm run test:integration:record |
||||||
|
``` |
||||||
|
|
||||||
|
Commit the updated `integration-fixtures/*.json` so others can run the integration test without a relay. Requires `nostr-tools` (devDependency) only when recording. |
||||||
@ -0,0 +1,519 @@ |
|||||||
|
= AsciiDoc Test Document |
||||||
|
Kismet Lee |
||||||
|
2.9, October 31, 2021: Fall incarnation |
||||||
|
:description: Test description |
||||||
|
:author: Kismet Lee |
||||||
|
:date: 2021-10-31 |
||||||
|
:version: 2.9 |
||||||
|
:status: Draft |
||||||
|
:keywords: AsciiDoc, Test, Document |
||||||
|
:category: Test |
||||||
|
:language: English |
||||||
|
|
||||||
|
== Bullet list |
||||||
|
|
||||||
|
This is a test unordered list with mixed bullets: |
||||||
|
|
||||||
|
* First item with a number 2. in it |
||||||
|
* Second item |
||||||
|
* Third item |
||||||
|
** Indented item |
||||||
|
** Indented item |
||||||
|
* Fourth item |
||||||
|
|
||||||
|
Another unordered list: |
||||||
|
|
||||||
|
* 1st item |
||||||
|
* 2nd item |
||||||
|
* third item containing _italic_ text |
||||||
|
** indented item |
||||||
|
** second indented item |
||||||
|
* fourth item |
||||||
|
|
||||||
|
This is a test ordered list with indented items: |
||||||
|
|
||||||
|
. First item |
||||||
|
. Second item |
||||||
|
. Third item |
||||||
|
.. Indented item |
||||||
|
.. Indented item |
||||||
|
. Fourth item |
||||||
|
|
||||||
|
Ordered list where everything has no number: |
||||||
|
|
||||||
|
. First item |
||||||
|
. Second item |
||||||
|
. Third item |
||||||
|
. Fourth item |
||||||
|
|
||||||
|
This is a mixed list with indented items: |
||||||
|
|
||||||
|
. First item |
||||||
|
. Second item |
||||||
|
. Third item |
||||||
|
* Indented item |
||||||
|
* Indented item |
||||||
|
. Fourth item |
||||||
|
|
||||||
|
This is another mixed list with indented items: |
||||||
|
|
||||||
|
* First item |
||||||
|
* Second item |
||||||
|
* Third item |
||||||
|
. Indented item |
||||||
|
. Indented item |
||||||
|
* Fourth item |
||||||
|
|
||||||
|
== Headers |
||||||
|
|
||||||
|
=== Third-level header |
||||||
|
|
||||||
|
==== Fourth-level header |
||||||
|
|
||||||
|
===== Fifth-level header |
||||||
|
|
||||||
|
[discrete] |
||||||
|
====== Sixth-level header |
||||||
|
|
||||||
|
This discrete header shouldn't become a section. It should just be rendered in the header style. |
||||||
|
|
||||||
|
== Media and Links |
||||||
|
|
||||||
|
=== Nostr address |
||||||
|
|
||||||
|
This should be ignored and rendered as plaintext: naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagss... |
||||||
|
|
||||||
|
This is also plaintext: npub1gv069uwhateverbunchofnumbers |
||||||
|
|
||||||
|
And this should stay plaintext, as it is just a prefix with no address: nostr: |
||||||
|
|
||||||
|
This nostr address should be ignored, since it is in a URL https://notanostraddress/nevent1qvzqqqqqqypzp382htsmu08k277ps40wqhnfm60st89h5pvjyutghq9cjasuh38qqythwumn8ghj7un9d3shjtnswf5k6ctv9ehx2ap0qqsysletg3lqnl4uy59xsj4rp9rgw67wg23l827f4uvn5ckn20fuxcq45d8pj |
||||||
|
|
||||||
|
These should be turned into links: |
||||||
|
|
||||||
|
naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyghwumn8ghj7mn0wd68ytnvv9hxgtcqy4sj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwm3dvfuj6enyxgcrset98p3nsve2v5l at the start of the line |
||||||
|
|
||||||
|
even this one npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z in the middle of the line |
||||||
|
|
||||||
|
These should be turned into links, but should only have one "nostr:" prefix: |
||||||
|
|
||||||
|
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyghwumn8ghj7mn0wd68ytnvv9hxgtcqy4sj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwm3dvfuj6enyxgcrset98p3nsve2v5l |
||||||
|
|
||||||
|
nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z |
||||||
|
|
||||||
|
nostr:nevent1qqs07p6r33p7yslp6lzk59k2fwlkudhnm4mg7chz5a0apeqfeluuyqsyay40e |
||||||
|
|
||||||
|
nostr:nprofile1qqsd6ejdteqpvse63ntf7qz6u9yqspp4z7ymt8094urzwm0x2ceaxxg5g7l0j |
||||||
|
|
||||||
|
nostr:note1uuw73zj2hmrtfqqzzff526msjvnl90n24q7z2lz6k4a4gknz94rqvl2846 |
||||||
|
|
||||||
|
=== Hashtag |
||||||
|
|
||||||
|
#testhashtag at the start of the line and #inlinehashtag in the middle |
||||||
|
|
||||||
|
=== Wikilinks |
||||||
|
|
||||||
|
[[NKBIP-01|NKBIP-01 Specification]] and [[mirepoix]] |
||||||
|
|
||||||
|
=== URL |
||||||
|
|
||||||
|
https://www.welt.de/politik/ausland/article69a7ca00ad41f3cd65a1bc63/iran-drohte-jedes-schiff-zu-verbrennen-trump-will-oel-tanker-durch-strasse-von-hormus-eskortieren.html |
||||||
|
|
||||||
|
link:https://www.welt.de/politik/ausland/article69a7ca00ad41f3cd65a1bc63/iran-drohte-jedes-schiff-zu-verbrennen-trump-will-oel-tanker-durch-strasse-von-hormus-eskortieren.html[Welt Online link] |
||||||
|
|
||||||
|
this should render as plaintext: `http://www.example.com` |
||||||
|
|
||||||
|
this should be a hyperlink to the http URL with the same address link:https://theforest.nostr1.com[wss://theforest.nostr1.com] |
||||||
|
|
||||||
|
=== Images |
||||||
|
|
||||||
|
https://blog.ronin.cloud/content/images/size/w2000/2022/02/markdown.png |
||||||
|
|
||||||
|
image::https://blog.ronin.cloud/content/images/size/w2000/2022/02/markdown.png[Markdown example,width=400] |
||||||
|
|
||||||
|
Here is an inline image:https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg[Linux,25,35]. This only works in AsciiDoc, not Markdown. |
||||||
|
|
||||||
|
=== Media |
||||||
|
|
||||||
|
==== YouTube |
||||||
|
|
||||||
|
Normal |
||||||
|
|
||||||
|
https://www.youtube.com/watch?v=KGIAS0cslSU |
||||||
|
|
||||||
|
https://youtu.be/KGIAS0cslSU |
||||||
|
|
||||||
|
video::KGIAS0cslSU[youtube] |
||||||
|
|
||||||
|
Shorts |
||||||
|
|
||||||
|
https://www.youtube.com/shorts/s-BQhXdCs8Y |
||||||
|
|
||||||
|
video::s-BQhXdCs8Y[youtube] |
||||||
|
|
||||||
|
==== Spotify |
||||||
|
|
||||||
|
https://open.spotify.com/episode/1GSZFA8vWltPyxYkArdRKx |
||||||
|
|
||||||
|
link:https://open.spotify.com/episode/1GSZFA8vWltPyxYkArdRKx[Spotify example] |
||||||
|
|
||||||
|
==== Audio |
||||||
|
|
||||||
|
https://media.blubrry.com/takeituneasy/ins.blubrry.com/takeituneasy/lex_ai_rick_beato.mp3 |
||||||
|
|
||||||
|
audio::https://media.blubrry.com/takeituneasy/ins.blubrry.com/takeituneasy/lex_ai_rick_beato.mp3[Audio example] |
||||||
|
|
||||||
|
==== Video |
||||||
|
|
||||||
|
https://v.nostr.build/MTjaYib4upQuf8zn.mp4 |
||||||
|
|
||||||
|
video::https://v.nostr.build/MTjaYib4upQuf8zn.mp4[Video example] |
||||||
|
|
||||||
|
== Tables |
||||||
|
|
||||||
|
=== Orderly |
||||||
|
|
||||||
|
[cols="1,2"] |
||||||
|
|=== |
||||||
|
|Syntax|Description |
||||||
|
|
||||||
|
|Header |
||||||
|
|Title |
||||||
|
|
||||||
|
|Paragraph |
||||||
|
|Text |
||||||
|
|=== |
||||||
|
|
||||||
|
=== Unorderly |
||||||
|
|
||||||
|
[cols="1,2"] |
||||||
|
|=== |
||||||
|
|Syntax|Description |
||||||
|
|
||||||
|
|Header |
||||||
|
|Title |
||||||
|
|
||||||
|
|Paragraph |
||||||
|
|Text |
||||||
|
|=== |
||||||
|
|
||||||
|
=== With alignment |
||||||
|
|
||||||
|
[cols="<,^,>"] |
||||||
|
|=== |
||||||
|
|Syntax|Description|Test Text |
||||||
|
|
||||||
|
|Header |
||||||
|
|Title |
||||||
|
|Here's this |
||||||
|
|
||||||
|
|Paragraph |
||||||
|
|Text |
||||||
|
|And more |
||||||
|
|=== |
||||||
|
|
||||||
|
[grid=rows] |
||||||
|
|=== |
||||||
|
|Column 1, header row |Column 2, header row |Column 3, header row |
||||||
|
|
||||||
|
|Cell in column 1, row 2 |
||||||
|
|Cell in column 2, row 2 |
||||||
|
|Cell in column 3, row 2 |
||||||
|
|
||||||
|
|Cell in column 1, row 3 |
||||||
|
|Cell in column 2, row 3 |
||||||
|
|Cell in column 3, row 3 |
||||||
|
|=== |
||||||
|
|
||||||
|
[grid=cols] |
||||||
|
|=== |
||||||
|
|Column 1, header row |Column 2, header row |Column 3, header row |
||||||
|
|
||||||
|
|Cell in column 1, row 2 |
||||||
|
|Cell in column 2, row 2 |
||||||
|
|Cell in column 3, row 2 |
||||||
|
|
||||||
|
|Cell in column 1, row 3 |
||||||
|
|Cell in column 2, row 3 |
||||||
|
|Cell in column 3, row 3 |
||||||
|
|=== |
||||||
|
|
||||||
|
[grid=none] |
||||||
|
|=== |
||||||
|
|Column 1, header row |Column 2, header row |Column 3, header row |
||||||
|
|
||||||
|
|Cell in column 1, row 2 |
||||||
|
|Cell in column 2, row 2 |
||||||
|
|Cell in column 3, row 2 |
||||||
|
|
||||||
|
|Cell in column 1, row 3 |
||||||
|
|Cell in column 2, row 3 |
||||||
|
|Cell in column 3, row 3 |
||||||
|
|=== |
||||||
|
|
||||||
|
[width=75%] |
||||||
|
|=== |
||||||
|
|Column 1, header row |Column 2, header row |Column 3, header row |
||||||
|
|
||||||
|
|Cell in column 1, row 2 |
||||||
|
|Cell in column 2, row 2 |
||||||
|
|Cell in column 3, row 2 |
||||||
|
|
||||||
|
|Cell in column 1, row 3 |
||||||
|
|Cell in column 2, row 3 |
||||||
|
|Cell in column 3, row 3 |
||||||
|
|=== |
||||||
|
|
||||||
|
[%autowidth.stretch] |
||||||
|
|=== |
||||||
|
|Column 1, header row |Column 2, header row |Column 3, header row |
||||||
|
|
||||||
|
|Cell in column 1, row 2 |
||||||
|
|Cell in column 2, row 2 |
||||||
|
|Cell in column 3, row 2 |
||||||
|
|
||||||
|
|Cell in column 1, row 3 |
||||||
|
|Cell in column 2, row 3 |
||||||
|
|Cell in column 3, row 3 |
||||||
|
|=== |
||||||
|
|
||||||
|
[cols="5,3*"] |
||||||
|
|=== |
||||||
|
|Column 1 |Column 2 |Column 3 |Column 4 |
||||||
|
|
||||||
|
|Cell in column 1 |
||||||
|
|Cell in column 2 |
||||||
|
|Cell in column 3 |
||||||
|
|Cell in column 4 |
||||||
|
|=== |
||||||
|
|
||||||
|
== Code blocks |
||||||
|
|
||||||
|
This is inline code: `console.log("Hello, world!");` and these this is inline code, too: `console.log("Hello, world! This is a very long inline code that should not be split into multiple lines.");` |
||||||
|
|
||||||
|
=== json |
||||||
|
|
||||||
|
[source,json] |
||||||
|
---- |
||||||
|
{ |
||||||
|
"id": "<event_id>", |
||||||
|
"pubkey": "<event_originator_pubkey>", |
||||||
|
"created_at": 1725087283, |
||||||
|
"kind": 30040, |
||||||
|
"tags": [ |
||||||
|
["d", "aesop's-fables-by-aesop"], |
||||||
|
["title", "Aesop's Fables"], |
||||||
|
["author", "Aesop"], |
||||||
|
], |
||||||
|
"sig": "<event_signature>" |
||||||
|
} |
||||||
|
---- |
||||||
|
|
||||||
|
=== shell |
||||||
|
|
||||||
|
[source,shell] |
||||||
|
---- |
||||||
|
mkdir new_directory |
||||||
|
cp source.txt destination.txt |
||||||
|
|
||||||
|
---- |
||||||
|
|
||||||
|
### Inline-code |
||||||
|
|
||||||
|
`this is code` and `this is also code` |
||||||
|
|
||||||
|
== Footnotes |
||||||
|
|
||||||
|
Here's a simple footnote,footnote:[This is the first footnote.] and here's a longer one.footnote:[Here's one with multiple paragraphs and code.] |
||||||
|
|
||||||
|
== Anchor links |
||||||
|
|
||||||
|
<<_bullet_list,Link to bullet list section>> |
||||||
|
|
||||||
|
== Formatting |
||||||
|
|
||||||
|
=== Strikethrough |
||||||
|
|
||||||
|
~~The world is flat.~~ We now know that the world is round. This should not be ~struck~ through. |
||||||
|
|
||||||
|
=== Bold |
||||||
|
|
||||||
|
This is *bold* text. So is this *bold* text. |
||||||
|
|
||||||
|
=== Italic |
||||||
|
|
||||||
|
This is _italic_ text. So is this _italic_ text. |
||||||
|
|
||||||
|
=== Bold and italic Mixed |
||||||
|
|
||||||
|
This is **bold and _italic_** text. |
||||||
|
This is _italic and **bold**_ text. |
||||||
|
|
||||||
|
=== Task List |
||||||
|
|
||||||
|
* [x] Write the press release |
||||||
|
* [ ] Update the website |
||||||
|
* [ ] Contact the media |
||||||
|
|
||||||
|
=== Emoji shortcodes |
||||||
|
|
||||||
|
Gone camping! :tent: Be back soon. |
||||||
|
|
||||||
|
That is so funny! :joy: |
||||||
|
|
||||||
|
=== Marking and highlighting text |
||||||
|
|
||||||
|
I need to highlight these [highlight]#very important words#. |
||||||
|
|
||||||
|
=== Subscript and Superscript |
||||||
|
|
||||||
|
H~2~O |
||||||
|
|
||||||
|
X^2^ |
||||||
|
|
||||||
|
=== Delimiter |
||||||
|
|
||||||
|
based upon a single quote |
||||||
|
|
||||||
|
''' |
||||||
|
|
||||||
|
based upon a dashes |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
=== Quotes |
||||||
|
|
||||||
|
[quote] |
||||||
|
____ |
||||||
|
This is a single line blockequote sdfjsdlfkjasldkfjsdölfkjsdlfkjsadlöfkjsdlöfkjsadölfkjsdlf kjsldfkjsdalkjslkdfjlöskdfjlösdkjfsldkfjsöldkfjlösdkfjalsd kfjlsdkfjlödkfjlaksdfjlkjdfslkjalsdkfjlasdkfj alsdkjflskdfj sdfklj |
||||||
|
____ |
||||||
|
|
||||||
|
[quote,Monty Python and the Holy Grail] |
||||||
|
____ |
||||||
|
Dennis: Come and see the violence inherent in the system. Help! Help! I'm being repressed! |
||||||
|
|
||||||
|
King Arthur: Bloody peasant! |
||||||
|
|
||||||
|
Dennis: Oh, what a giveaway! Did you hear that? Did you hear that, eh? That's what I'm on about! Did you see him repressing me? You saw him, Didn't you? |
||||||
|
|
||||||
|
> We should also support normal quotes. |
||||||
|
|
||||||
|
|
||||||
|
____ |
||||||
|
|
||||||
|
=== Green-text |
||||||
|
|
||||||
|
>This is green-text |
||||||
|
>It differs from quotes because there is no space between the chevron and the first letter in the line. |
||||||
|
>Should match greentext from 4chan. |
||||||
|
|
||||||
|
=== Keyboard shortcuts |
||||||
|
|
||||||
|
|=== |
||||||
|
|Shortcut |Purpose |
||||||
|
|
||||||
|
|kbd:[F11] |
||||||
|
|Toggle fullscreen |
||||||
|
|
||||||
|
|kbd:[Ctrl+T] |
||||||
|
|Open a new tab |
||||||
|
|
||||||
|
|kbd:[Ctrl+Shift+N] |
||||||
|
|New incognito window |
||||||
|
|
||||||
|
|kbd:[\ ] |
||||||
|
|Used to escape characters |
||||||
|
|
||||||
|
|kbd:[Ctrl+\]] |
||||||
|
|Jump to keyword |
||||||
|
|
||||||
|
|kbd:[Ctrl + +] |
||||||
|
|Increase zoom |
||||||
|
|=== |
||||||
|
|
||||||
|
== Admonitions |
||||||
|
|
||||||
|
[NOTE] |
||||||
|
==== |
||||||
|
This is a note. |
||||||
|
==== |
||||||
|
|
||||||
|
[WARNING] |
||||||
|
==== |
||||||
|
This is a warning. |
||||||
|
==== |
||||||
|
|
||||||
|
[TIP] |
||||||
|
==== |
||||||
|
This is a tip. |
||||||
|
==== |
||||||
|
|
||||||
|
[IMPORTANT] |
||||||
|
==== |
||||||
|
This is an important message. |
||||||
|
==== |
||||||
|
|
||||||
|
[CAUTION] |
||||||
|
==== |
||||||
|
This is a caution. |
||||||
|
==== |
||||||
|
|
||||||
|
WARNING: Wolpertingers are known to nest in server racks. |
||||||
|
Enter at your own risk. |
||||||
|
|
||||||
|
== Sidebars |
||||||
|
|
||||||
|
[sidebar] |
||||||
|
==== |
||||||
|
This is a sidebar. |
||||||
|
==== |
||||||
|
|
||||||
|
[sidebar] |
||||||
|
Sidebars are used to visually separate auxiliary bits of content |
||||||
|
that supplement the main text. |
||||||
|
|
||||||
|
.Optional Title |
||||||
|
**** |
||||||
|
Sidebars are used to visually separate auxiliary bits of content |
||||||
|
that supplement the main text. |
||||||
|
|
||||||
|
TIP: They can contain any type of content. |
||||||
|
|
||||||
|
.Source code block in a sidebar |
||||||
|
[source,js] |
||||||
|
---- |
||||||
|
const { expect, expectCalledWith, heredoc } = require('../test/test-utils') |
||||||
|
---- |
||||||
|
**** |
||||||
|
|
||||||
|
== Examples |
||||||
|
|
||||||
|
.Optional title |
||||||
|
[example] |
||||||
|
This is an example of an example block. |
||||||
|
|
||||||
|
.Onomatopoeia |
||||||
|
==== |
||||||
|
The book hit the floor with a *thud*. |
||||||
|
|
||||||
|
He could hear doves *cooing* in the pine trees`' branches. |
||||||
|
==== |
||||||
|
|
||||||
|
== Literal blocks |
||||||
|
|
||||||
|
[literal] |
||||||
|
error: 1954 Forbidden search |
||||||
|
absolutely fatal: operation lost in the dodecahedron of doom |
||||||
|
Would you like to try again? y/n |
||||||
|
|
||||||
|
.... |
||||||
|
Kismet: Where is the *defensive operations manual*? |
||||||
|
|
||||||
|
Computer: Calculating ... |
||||||
|
Can not locate object. |
||||||
|
You are not authorized to know it exists. |
||||||
|
|
||||||
|
Kismet: Did the werewolves tell you to say that? |
||||||
|
|
||||||
|
Computer: Calculating ... |
||||||
|
.... |
||||||
@ -0,0 +1,184 @@ |
|||||||
|
{ |
||||||
|
"indexEvent": { |
||||||
|
"kind": 30040, |
||||||
|
"content": "", |
||||||
|
"tags": [ |
||||||
|
[ |
||||||
|
"d", |
||||||
|
"document-test" |
||||||
|
], |
||||||
|
[ |
||||||
|
"title", |
||||||
|
"Document Test" |
||||||
|
], |
||||||
|
[ |
||||||
|
"author", |
||||||
|
"unknown" |
||||||
|
], |
||||||
|
[ |
||||||
|
"version", |
||||||
|
"1" |
||||||
|
], |
||||||
|
[ |
||||||
|
"m", |
||||||
|
"application/json" |
||||||
|
], |
||||||
|
[ |
||||||
|
"M", |
||||||
|
"meta-data/index/replaceable" |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document" |
||||||
|
], |
||||||
|
[ |
||||||
|
"t", |
||||||
|
"a-tags" |
||||||
|
], |
||||||
|
[ |
||||||
|
"t", |
||||||
|
"testfile" |
||||||
|
], |
||||||
|
[ |
||||||
|
"t", |
||||||
|
"asciimath" |
||||||
|
], |
||||||
|
[ |
||||||
|
"t", |
||||||
|
"latexmath" |
||||||
|
], |
||||||
|
[ |
||||||
|
"image", |
||||||
|
"https://i.nostr.build/5kWwbDR04joIASVx.png" |
||||||
|
] |
||||||
|
], |
||||||
|
"pubkey": "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"id": "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef" |
||||||
|
}, |
||||||
|
"sectionEvents": [ |
||||||
|
{ |
||||||
|
"kind": 30041, |
||||||
|
"content": "This is a paragraph with a *bold* word and an _italicized_ word.\n\n.Image caption\nimage::https://upload.wikimedia.org/wikipedia/commons/1/11/Test-Logo.svg[I am the image alt text.]\n\nThis is another paragraph.footnote:[I am footnote text and will be displayed at the bottom of the article.]\n\n.Unordered list title\n* list item 1\n** nested list item\n*** nested nested list item 1\n*** nested nested list item 2\n* list item 2\n\nThis is a paragraph.\n\n.Example block title\n....\nContent in an example block is subject to normal substitutions.\n....\n\n.Sidebar title\n****\nSidebars contain aside text and are subject to normal substitutions.\n****\n\n[#id-for-listing-block]\n.Listing block title\n----\nContent in a listing block is subject to verbatim substitutions.\nListing block content is commonly used to preserve code input.\n----", |
||||||
|
"tags": [ |
||||||
|
[ |
||||||
|
"d", |
||||||
|
"first-level-heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"title", |
||||||
|
"First level heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"image", |
||||||
|
"https://i.nostr.build/5kWwbDR04joIASVx.png" |
||||||
|
], |
||||||
|
[ |
||||||
|
"m", |
||||||
|
"text/asciidoc" |
||||||
|
], |
||||||
|
[ |
||||||
|
"M", |
||||||
|
"article/publication-content/replaceable" |
||||||
|
] |
||||||
|
], |
||||||
|
"pubkey": "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"id": "6763b635ab8d7a308277892b570616b346765c9eef8d836541f21231bef26a04" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"kind": 30041, |
||||||
|
"content": "[quote, firstname lastname, movie title]\n____\nI am a block quote or a prose excerpt.\nI am subject to normal substitutions.\n____\n\n[verse, firstname lastname, poem title and more]\n____\nI am a verse block.\n Indents and endlines are preserved in verse blocks.\n____\n\n\nTIP: There are five admonition labels: Tip, Note, Important, Caution and Warning.\n\n// I am a comment and won't be rendered.\n\n. ordered list item\n.. nested ordered list item\n. ordered list item\n\nThe text at the end of this sentence is cross referenced to <<_third_level_heading,the third level heading>>", |
||||||
|
"tags": [ |
||||||
|
[ |
||||||
|
"d", |
||||||
|
"another-first-level-heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"title", |
||||||
|
"Another first-level heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"image", |
||||||
|
"https://i.nostr.build/5kWwbDR04joIASVx.png" |
||||||
|
], |
||||||
|
[ |
||||||
|
"m", |
||||||
|
"text/asciidoc" |
||||||
|
], |
||||||
|
[ |
||||||
|
"M", |
||||||
|
"article/publication-content/replaceable" |
||||||
|
] |
||||||
|
], |
||||||
|
"pubkey": "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"id": "cba23f9343a79963855d7649bf7164b9fe2c6b92f661ac12ebf9a224386e5d09" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"kind": 30041, |
||||||
|
"content": "This is a link to the https://asciidoctor.org/docs/user-manual/[Asciidoctor User Manual].\nThis is an attribute reference {quick-uri}[which links this text to the Asciidoctor Quick Reference Guide].", |
||||||
|
"tags": [ |
||||||
|
[ |
||||||
|
"d", |
||||||
|
"a-third-first-level-heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"title", |
||||||
|
"A third first-level heading" |
||||||
|
], |
||||||
|
[ |
||||||
|
"image", |
||||||
|
"https://i.nostr.build/5kWwbDR04joIASVx.png" |
||||||
|
], |
||||||
|
[ |
||||||
|
"m", |
||||||
|
"text/asciidoc" |
||||||
|
], |
||||||
|
[ |
||||||
|
"M", |
||||||
|
"article/publication-content/replaceable" |
||||||
|
] |
||||||
|
], |
||||||
|
"pubkey": "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"id": "078f52ef883444279d327980a21340d02891d6aed6c405e8b73a722a5cd7b93b" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"kind": 30041, |
||||||
|
"content": "=== Basic Math Expressions\n\nHere's a simple equation: asciimath:[sqrt(4) = 2]\n\nAnd an inline expression with asciimath notation: asciimath:[x^2 + y^2 = z^2]\n\n=== Block Math Expressions\n\nHere's a block equation:\n\n[asciimath]\n++++\nsum_(i=1)^n i^3=((n(n+1))/2)^2\n++++\n\nAnd another one with explicit asciimath notation:\n\n[asciimath]\n++++\nf(x) = int_{-infty}^x~e^{-t^2}dt\n++++\n\n=== Complex Expressions\n\nHere's a more complex expression:\n\n[asciimath]\n++++\nlim_(N->oo) sum_(i=1)^N i = (N(N+1))/2\n++++\n\nAnd a matrix:\n\n[asciimath]\n++++\n[[a,b],[c,d]]\n++++\n\n=== LaTeX Math\n\nWe can also use LaTeX math notation:\n\n[latexmath]\n++++\n\\frac{n!}{k!(n-k)!} = \\binom{n}{k}\n++++", |
||||||
|
"tags": [ |
||||||
|
[ |
||||||
|
"d", |
||||||
|
"asciimath-test-document" |
||||||
|
], |
||||||
|
[ |
||||||
|
"title", |
||||||
|
"AsciiMath Test Document" |
||||||
|
], |
||||||
|
[ |
||||||
|
"image", |
||||||
|
"https://i.nostr.build/5kWwbDR04joIASVx.png" |
||||||
|
], |
||||||
|
[ |
||||||
|
"m", |
||||||
|
"text/asciidoc" |
||||||
|
], |
||||||
|
[ |
||||||
|
"M", |
||||||
|
"article/publication-content/replaceable" |
||||||
|
] |
||||||
|
], |
||||||
|
"pubkey": "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"id": "f204287665877f55feb9bf1de52b644a257009228d93c5622e74c5f223a115c2" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,35 @@ |
|||||||
|
{ |
||||||
|
"name": "gc-parser-asciidoc", |
||||||
|
"author": "Silberengel", |
||||||
|
"company": "GitCitadel", |
||||||
|
"version": "0.1.0", |
||||||
|
"description": "Basic AsciiDoc parser and kind 30040 publication helper for Nostr", |
||||||
|
"main": "dist/index.js", |
||||||
|
"types": "dist/index.d.ts", |
||||||
|
"files": [ |
||||||
|
"dist", |
||||||
|
"README.md" |
||||||
|
], |
||||||
|
"scripts": { |
||||||
|
"build": "tsc", |
||||||
|
"test": "vitest run", |
||||||
|
"test:watch": "vitest", |
||||||
|
"test:integration": "RUN_INTEGRATION=1 vitest run src/integration.test.ts", |
||||||
|
"test:integration:record": "RECORD_FIXTURES=1 RUN_INTEGRATION=1 vitest run src/integration.test.ts" |
||||||
|
}, |
||||||
|
"keywords": [ |
||||||
|
"nostr", |
||||||
|
"asciidoc", |
||||||
|
"parser", |
||||||
|
"30040", |
||||||
|
"publication", |
||||||
|
"typescript" |
||||||
|
], |
||||||
|
"license": "MIT", |
||||||
|
"devDependencies": { |
||||||
|
"@types/node": "^20.10.0", |
||||||
|
"nostr-tools": "^2.23.3", |
||||||
|
"typescript": "^5.3.0", |
||||||
|
"vitest": "^2.0.0" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
export { parseAsciiDoc, renderEventContent } from './parser.js'; |
||||||
|
export { |
||||||
|
buildPublicationAsciiDoc, |
||||||
|
renderPublication, |
||||||
|
renderPublicationFromEvents, |
||||||
|
renderEvent, |
||||||
|
toPublicationIndexLike, |
||||||
|
} from './publication-helper.js'; |
||||||
|
export type { |
||||||
|
AsciiDocParserOptions, |
||||||
|
AsciiDocParseResult, |
||||||
|
NostrEventLike, |
||||||
|
PublicationIndexLike, |
||||||
|
PublicationSection, |
||||||
|
PublicationInput, |
||||||
|
PublicationRenderResult, |
||||||
|
} from './types.js'; |
||||||
@ -0,0 +1,134 @@ |
|||||||
|
/** |
||||||
|
* Inline formatting: hashtags, nostr links, wikilinks, URLs, |
||||||
|
* bold, italic, code, strikethrough, subscript, superscript. |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { AsciiDocParserOptions } from './types.js'; |
||||||
|
|
||||||
|
const CODE_PLACEHOLDER = '\u0000CODE\u0000'; |
||||||
|
|
||||||
|
/** Bech32 nostr prefixes; min length after prefix to avoid false positives */ |
||||||
|
const NOSTR_PREFIX = /(?:nostr:)?(naddr1|npub1|nevent1|nprofile1|note1)([a-zA-HJ-NP-Z0-9]{20,})/g; |
||||||
|
|
||||||
|
/** Hashtag: #word (word = alphanumeric + underscore, not inside URL or word) */ |
||||||
|
const HASHTAG = /(?:^|(?<=[\s>)]))#([a-zA-Z][a-zA-Z0-9_]*)/g; |
||||||
|
|
||||||
|
/** Wikilink: [[id]] or [[id|label]] */ |
||||||
|
const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g; |
||||||
|
|
||||||
|
/** Bare URL (not in backticks); greedy so we consume trailing path */ |
||||||
|
const BARE_URL = /(https?:\/\/[^\s<>"']+)/g; |
||||||
|
|
||||||
|
/** Strikethrough ~~text~~ */ |
||||||
|
const STRIKETHROUGH = /~~([^~]+)~~/g; |
||||||
|
|
||||||
|
/** Subscript ~x~ (non-greedy, avoid matching ~~) */ |
||||||
|
const SUBSCRIPT = /(?<![~])~([^~\s]+)~/g; |
||||||
|
|
||||||
|
/** Superscript ^x^ */ |
||||||
|
const SUPERSCRIPT = /\^([^\^\s]+)\^/g; |
||||||
|
|
||||||
|
/** Bold *text* (not at BOL for list) */ |
||||||
|
const BOLD = /\*([^*]+)\*/g; |
||||||
|
|
||||||
|
/** Italic _text_ */ |
||||||
|
const ITALIC = /_([^_]+)_/g; |
||||||
|
|
||||||
|
/** Inline code `code` */ |
||||||
|
const INLINE_CODE = /`([^`]+)`/g; |
||||||
|
|
||||||
|
/** link:url[text] */ |
||||||
|
const LINK_MACRO = /link:(https?:\/\/[^\s\[\]]+)\[([^\]]*)\]/g; |
||||||
|
|
||||||
|
function escapeHtml(s: string): string { |
||||||
|
return s |
||||||
|
.replace(/&/g, '&') |
||||||
|
.replace(/</g, '<') |
||||||
|
.replace(/>/g, '>') |
||||||
|
.replace(/"/g, '"'); |
||||||
|
} |
||||||
|
|
||||||
|
function buildNostrHref(bech32: string, options: AsciiDocParserOptions): string { |
||||||
|
const base = options.nostrBaseUrl ?? ''; |
||||||
|
if (base === '') return 'nostr:' + bech32; |
||||||
|
const sep = base.endsWith('/') ? '' : '/'; |
||||||
|
return base + sep + bech32; |
||||||
|
} |
||||||
|
|
||||||
|
/** Linkify nostr addresses in text. */ |
||||||
|
function linkifyNostr(segment: string, options: AsciiDocParserOptions): string { |
||||||
|
return segment.replace(NOSTR_PREFIX, (fullMatch: string, prefix: string, rest: string) => { |
||||||
|
const bech32 = prefix + rest; |
||||||
|
const href = buildNostrHref(bech32, options); |
||||||
|
const label = fullMatch.startsWith('nostr:') ? 'nostr:' + bech32 : bech32; |
||||||
|
return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** Apply hashtag spans; run on already-escaped text so we only wrap #word */ |
||||||
|
function applyHashtags(text: string, options: AsciiDocParserOptions): string { |
||||||
|
const cls = options.hashtagClass ?? 'hashtag'; |
||||||
|
return text.replace(HASHTAG, (_, tag) => { |
||||||
|
return `<span class="${escapeHtml(cls)}">#${escapeHtml(tag)}</span>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function applyWikilinks(text: string, options: AsciiDocParserOptions): string { |
||||||
|
const urlFn = options.wikilinkUrl; |
||||||
|
return text.replace(WIKILINK, (_, dtag: string, label?: string) => { |
||||||
|
const href = urlFn |
||||||
|
? typeof urlFn === 'function' |
||||||
|
? urlFn(dtag) |
||||||
|
: urlFn.replace('{dtag}', dtag) |
||||||
|
: '#' + dtag.replace(/\s+/g, '_').toLowerCase(); |
||||||
|
const linkText = (label ?? dtag).trim(); |
||||||
|
return `<a href="${escapeHtml(href)}">${escapeHtml(linkText)}</a>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** Replace bare URLs with <a>; skip if the "URL" is actually part of nostr (e.g. nostr: in middle). */ |
||||||
|
function applyBareUrls(text: string): string { |
||||||
|
return text.replace(BARE_URL, (url) => { |
||||||
|
if (/^https?:\/\/[^/]*nostr:/i.test(url)) return url; |
||||||
|
return `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function applyLinkMacro(text: string): string { |
||||||
|
return text.replace(LINK_MACRO, (_, url: string, label: string) => { |
||||||
|
const t = label.trim(); |
||||||
|
return `<a href="${escapeHtml(url)}">${escapeHtml(t || url)}</a>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process inline content: escape, code, bold, italic, strikethrough, |
||||||
|
* sub, sup, hashtags, wikilinks, link macro, bare URLs, nostr links. |
||||||
|
*/ |
||||||
|
export function formatInline(text: string, options: AsciiDocParserOptions = {}): string { |
||||||
|
let out = escapeHtml(text); |
||||||
|
|
||||||
|
const codeBlocks: string[] = []; |
||||||
|
out = out.replace(INLINE_CODE, (_, code) => { |
||||||
|
codeBlocks.push(code); |
||||||
|
return CODE_PLACEHOLDER + (codeBlocks.length - 1) + CODE_PLACEHOLDER; |
||||||
|
}); |
||||||
|
|
||||||
|
out = out.replace(BOLD, (_, s) => '<strong>' + s + '</strong>'); |
||||||
|
out = out.replace(ITALIC, (_, s) => '<em>' + s + '</em>'); |
||||||
|
out = out.replace(STRIKETHROUGH, (_, s) => '<del>' + s + '</del>'); |
||||||
|
out = out.replace(SUBSCRIPT, (_, s) => '<sub>' + escapeHtml(s) + '</sub>'); |
||||||
|
out = out.replace(SUPERSCRIPT, (_, s) => '<sup>' + escapeHtml(s) + '</sup>'); |
||||||
|
out = applyHashtags(out, options); |
||||||
|
out = applyWikilinks(out, options); |
||||||
|
out = applyLinkMacro(out); |
||||||
|
out = applyBareUrls(out); |
||||||
|
out = linkifyNostr(out, options); |
||||||
|
|
||||||
|
out = out.replace(new RegExp(CODE_PLACEHOLDER + '(\\d+)' + CODE_PLACEHOLDER, 'g'), (_, n) => { |
||||||
|
const code = codeBlocks[parseInt(n, 10)] ?? ''; |
||||||
|
return '<code>' + escapeHtml(code) + '</code>'; |
||||||
|
}); |
||||||
|
|
||||||
|
return out; |
||||||
|
} |
||||||
@ -0,0 +1,167 @@ |
|||||||
|
/** |
||||||
|
* Integration tests: render from fixture events (no relay required). |
||||||
|
* Fixtures live in integration-fixtures/; record them once with: |
||||||
|
* RECORD_FIXTURES=1 RUN_INTEGRATION=1 npm run test:integration |
||||||
|
* Then run with: RUN_INTEGRATION=1 npm run test:integration |
||||||
|
* Output HTML is written to integration-output/ for inspection. |
||||||
|
*/ |
||||||
|
|
||||||
|
import { describe, it, expect, afterAll } from 'vitest'; |
||||||
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
import { SimplePool, nip19 } from 'nostr-tools'; |
||||||
|
import type { Filter } from 'nostr-tools'; |
||||||
|
import { renderEvent, renderPublicationFromEvents } from './index.js'; |
||||||
|
import type { NostrEventLike } from './types.js'; |
||||||
|
|
||||||
|
const RELAY = 'wss://thecitadel.nostr1.com'; |
||||||
|
const OUTPUT_DIR = join(process.cwd(), 'integration-output'); |
||||||
|
const FIXTURES_DIR = join(process.cwd(), 'integration-fixtures'); |
||||||
|
const SINGLE_EVENT_FIXTURE = join(FIXTURES_DIR, 'single-event.json'); |
||||||
|
const PUBLICATION_FIXTURE = join(FIXTURES_DIR, 'publication.json'); |
||||||
|
|
||||||
|
// Single event (30818 or 30041)
|
||||||
|
const NADDR_SINGLE = |
||||||
|
'naddr1qvzqqqr4typzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpzfmhxue69uhkummnw3eryvfwvdhk6tcqzfshxcmfd9jx7cedw3jhxapddehhgegxwscd3'; |
||||||
|
|
||||||
|
// Publication (30040 + 30041s)
|
||||||
|
const NADDR_PUBLICATION = |
||||||
|
'naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyd8wumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6qg6waehxw309ahhymre94ex2mrp0yhxjmthv9kxgtn9w5q3qamnwvaz7tmwdaehgu3wd3skueqpr9mhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwsq3kamnwvaz7tm5dpjkx6t5v9jx2mpwdehhxarjxyhxxmmdqyg8wumn8ghj7mn0wd68ytnhd9hx2qghwaehxw309ahx7um5wgh8xmmkvf5hgtngdaehgqg3waehxw309ahx7um5wgerztnrdaksqrtyda3h2mt9de6z6ar9wd6qr2h2sv'; |
||||||
|
|
||||||
|
const runIntegration = !!process.env.RUN_INTEGRATION; |
||||||
|
const recordFixtures = !!process.env.RECORD_FIXTURES; |
||||||
|
|
||||||
|
function toNostrEventLike(evt: { kind: number; content: string; tags: string[][]; pubkey: string; id?: string }): NostrEventLike { |
||||||
|
return { |
||||||
|
kind: evt.kind, |
||||||
|
content: evt.content, |
||||||
|
tags: evt.tags, |
||||||
|
pubkey: evt.pubkey, |
||||||
|
id: evt.id, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function writeHtml(filename: string, body: string, toc = '') { |
||||||
|
mkdirSync(OUTPUT_DIR, { recursive: true }); |
||||||
|
const fullPath = join(OUTPUT_DIR, filename); |
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||||
|
<title>${filename.replace('.html', '')}</title> |
||||||
|
<style> |
||||||
|
body { font-family: system-ui, sans-serif; max-width: 60em; margin: 0 auto; padding: 1rem; line-height: 1.5; } |
||||||
|
.toc { margin-bottom: 2rem; padding: 1rem; background: #f5f5f5; border-radius: 0.5rem; } |
||||||
|
.toc ul { list-style: none; padding-left: 0; } |
||||||
|
.toc a { color: #0066cc; } |
||||||
|
.hashtag { color: #6b21a8; text-decoration: none; } |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
${toc ? `<div class="toc"><h2>Contents</h2>${toc}</div>` : ''} |
||||||
|
${body} |
||||||
|
</body> |
||||||
|
</html>`;
|
||||||
|
writeFileSync(fullPath, html, 'utf-8'); |
||||||
|
console.log(`Wrote ${fullPath}`); |
||||||
|
} |
||||||
|
|
||||||
|
function saveFixture(path: string, data: unknown) { |
||||||
|
mkdirSync(FIXTURES_DIR, { recursive: true }); |
||||||
|
writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8'); |
||||||
|
console.log(`Wrote fixture ${path}`); |
||||||
|
} |
||||||
|
|
||||||
|
describe.skipIf(!runIntegration)('integration: render from fixtures (or record from relay)', () => { |
||||||
|
const pool = new SimplePool(); |
||||||
|
|
||||||
|
afterAll(() => { |
||||||
|
pool.destroy(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('single event (30818/30041): load from fixture or fetch and record, then render HTML', async () => { |
||||||
|
let event: NostrEventLike; |
||||||
|
|
||||||
|
if (existsSync(SINGLE_EVENT_FIXTURE) && !recordFixtures) { |
||||||
|
const raw = JSON.parse(readFileSync(SINGLE_EVENT_FIXTURE, 'utf-8')); |
||||||
|
event = raw as NostrEventLike; |
||||||
|
} else { |
||||||
|
const decoded = nip19.decode(NADDR_SINGLE); |
||||||
|
if (decoded.type !== 'naddr') throw new Error('expected naddr'); |
||||||
|
const { kind, pubkey, identifier } = decoded.data; |
||||||
|
const filter: Filter = { |
||||||
|
kinds: [kind], |
||||||
|
authors: [pubkey], |
||||||
|
limit: 1, |
||||||
|
}; |
||||||
|
if (identifier) filter['#d'] = [identifier]; |
||||||
|
const ev = await pool.get([RELAY], filter, { maxWait: 10000 }); |
||||||
|
expect(ev).not.toBeNull(); |
||||||
|
if (!ev) return; |
||||||
|
event = toNostrEventLike(ev); |
||||||
|
if (recordFixtures) saveFixture(SINGLE_EVENT_FIXTURE, event); |
||||||
|
} |
||||||
|
|
||||||
|
const html = renderEvent(event); |
||||||
|
expect(html).toBeDefined(); |
||||||
|
expect(typeof html).toBe('string'); |
||||||
|
expect(html.length).toBeGreaterThan(0); |
||||||
|
expect(html).toMatch(/<[a-z]+/); |
||||||
|
writeHtml('single-event.html', html); |
||||||
|
}, recordFixtures ? 15000 : 5000); |
||||||
|
|
||||||
|
it('publication (30040): load from fixture or fetch and record, then render one HTML page', async () => { |
||||||
|
let indexEvent: NostrEventLike; |
||||||
|
let sectionEvents: NostrEventLike[]; |
||||||
|
|
||||||
|
if (existsSync(PUBLICATION_FIXTURE) && !recordFixtures) { |
||||||
|
const raw = JSON.parse(readFileSync(PUBLICATION_FIXTURE, 'utf-8')) as { |
||||||
|
indexEvent: NostrEventLike; |
||||||
|
sectionEvents: NostrEventLike[]; |
||||||
|
}; |
||||||
|
indexEvent = raw.indexEvent; |
||||||
|
sectionEvents = raw.sectionEvents; |
||||||
|
} else { |
||||||
|
const decoded = nip19.decode(NADDR_PUBLICATION); |
||||||
|
if (decoded.type !== 'naddr') throw new Error('expected naddr'); |
||||||
|
const { kind, pubkey, identifier } = decoded.data; |
||||||
|
expect(kind).toBe(30040); |
||||||
|
const indexFilter: Filter = { |
||||||
|
kinds: [30040], |
||||||
|
authors: [pubkey], |
||||||
|
limit: 1, |
||||||
|
}; |
||||||
|
if (identifier) indexFilter['#d'] = [identifier]; |
||||||
|
const indexEv = await pool.get([RELAY], indexFilter, { maxWait: 15000 }); |
||||||
|
expect(indexEv).not.toBeNull(); |
||||||
|
if (!indexEv) return; |
||||||
|
indexEvent = toNostrEventLike(indexEv); |
||||||
|
const aTags = indexEv.tags.filter((t) => t[0] === 'a').map((t) => t[1]); |
||||||
|
sectionEvents = []; |
||||||
|
for (const a of aTags) { |
||||||
|
const parts = a.split(':'); |
||||||
|
if (parts.length < 3) continue; |
||||||
|
const [kindStr, author, d] = parts; |
||||||
|
const sectionKind = parseInt(kindStr!, 10); |
||||||
|
const sectionFilter: Filter = { |
||||||
|
kinds: [sectionKind], |
||||||
|
authors: [author], |
||||||
|
'#d': [d], |
||||||
|
limit: 1, |
||||||
|
}; |
||||||
|
const ev = await pool.get([RELAY], sectionFilter, { maxWait: 8000 }); |
||||||
|
if (ev) sectionEvents.push(toNostrEventLike(ev)); |
||||||
|
} |
||||||
|
if (recordFixtures) saveFixture(PUBLICATION_FIXTURE, { indexEvent, sectionEvents }); |
||||||
|
} |
||||||
|
|
||||||
|
const result = renderPublicationFromEvents(indexEvent, sectionEvents, { toc: true }); |
||||||
|
expect(result.html).toBeDefined(); |
||||||
|
expect(result.html.length).toBeGreaterThan(0); |
||||||
|
expect(result.html).toMatch(/<h[12]/); |
||||||
|
expect(result.tableOfContents).toBeDefined(); |
||||||
|
expect(result.asciidoc).toBeDefined(); |
||||||
|
writeHtml('publication.html', result.html, result.tableOfContents); |
||||||
|
}, recordFixtures ? 45000 : 5000); |
||||||
|
}); |
||||||
@ -0,0 +1,111 @@ |
|||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
import { parseAsciiDoc, renderEventContent } from './parser.js'; |
||||||
|
|
||||||
|
describe('parseAsciiDoc', () => { |
||||||
|
it('renders a simple paragraph', () => { |
||||||
|
const result = parseAsciiDoc('Hello, world.'); |
||||||
|
expect(result.html).toContain('Hello, world.'); |
||||||
|
expect(result.html).toMatch(/<p[^>]*>[\s\S]*Hello, world\./); |
||||||
|
expect(result.tableOfContents).toBe(''); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renders level-1 heading as h1', () => { |
||||||
|
const result = parseAsciiDoc('= The Title'); |
||||||
|
expect(result.html).toContain('The Title'); |
||||||
|
expect(result.html).toMatch(/<h1[^>]*>[\s\S]*The Title/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renders level-2 heading as h2', () => { |
||||||
|
const result = parseAsciiDoc('== Section'); |
||||||
|
expect(result.html).toMatch(/<h2[^>]*>[\s\S]*Section/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renders bold and italic', () => { |
||||||
|
const result = parseAsciiDoc('*bold* and _italic_'); |
||||||
|
expect(result.html).toContain('bold'); |
||||||
|
expect(result.html).toContain('italic'); |
||||||
|
expect(result.html).toMatch(/<strong[^>]*>[\s\S]*bold/); |
||||||
|
expect(result.html).toMatch(/<em[^>]*>[\s\S]*italic/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns empty tableOfContents when toc is false', () => { |
||||||
|
const doc = `= Doc
|
||||||
|
== A |
||||||
|
== B`;
|
||||||
|
const result = parseAsciiDoc(doc, { toc: false }); |
||||||
|
expect(result.tableOfContents).toBe(''); |
||||||
|
}); |
||||||
|
|
||||||
|
it('extracts table of contents when toc is true', () => { |
||||||
|
const doc = `= Doc
|
||||||
|
== Section A |
||||||
|
== Section B`;
|
||||||
|
const result = parseAsciiDoc(doc, { toc: true }); |
||||||
|
expect(result.tableOfContents).toContain('Section A'); |
||||||
|
expect(result.tableOfContents).toContain('Section B'); |
||||||
|
expect(result.html).toContain('Section A'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renders a list', () => { |
||||||
|
const result = parseAsciiDoc(`* one
|
||||||
|
* two`);
|
||||||
|
expect(result.html).toMatch(/<ul/); |
||||||
|
expect(result.html).toContain('one'); |
||||||
|
expect(result.html).toContain('two'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles empty input', () => { |
||||||
|
const result = parseAsciiDoc(''); |
||||||
|
expect(result.html).toBeDefined(); |
||||||
|
expect(typeof result.html).toBe('string'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renderEventContent returns HTML for single event content', () => { |
||||||
|
const html = renderEventContent('= One section\n\nSome *bold* text.'); |
||||||
|
expect(html).toContain('<h1'); |
||||||
|
expect(html).toContain('One section'); |
||||||
|
expect(html).toContain('<strong>bold</strong>'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('wraps #hashtags in span with class (no underline)', () => { |
||||||
|
const result = parseAsciiDoc('#start and #inlinehashtag here.'); |
||||||
|
expect(result.html).toContain('<span class="hashtag">#start</span>'); |
||||||
|
expect(result.html).toContain('<span class="hashtag">#inlinehashtag</span>'); |
||||||
|
expect(result.html).not.toMatch(/<a[^>]*#inlinehashtag/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('linkifies nostr addresses in normal text', () => { |
||||||
|
const adoc = 'See nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z for author.'; |
||||||
|
const result = parseAsciiDoc(adoc); |
||||||
|
expect(result.html).toContain('See '); |
||||||
|
expect(result.html).toMatch(/<a href="nostr:npub1[a-zA-Z0-9]+"/); |
||||||
|
expect(result.html).toContain('author.'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renders wikilinks [[id]] and [[id|label]]', () => { |
||||||
|
const result = parseAsciiDoc('[[mirepoix]] and [[NKBIP-01|NKBIP-01 Specification]].'); |
||||||
|
expect(result.html).toMatch(/<a href="#mirepoix">mirepoix<\/a>/); |
||||||
|
expect(result.html).toMatch(/<a href="#nkbip-01">NKBIP-01 Specification<\/a>/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renders strikethrough and subscript/superscript', () => { |
||||||
|
const result = parseAsciiDoc('~~struck~~ and H~2~O and X^2^.'); |
||||||
|
expect(result.html).toContain('<del>struck</del>'); |
||||||
|
expect(result.html).toContain('<sub>2</sub>'); |
||||||
|
expect(result.html).toContain('<sup>2</sup>'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('renders nested and ordered lists', () => { |
||||||
|
const doc = `* one
|
||||||
|
** nested |
||||||
|
* two |
||||||
|
|
||||||
|
. first |
||||||
|
. second`;
|
||||||
|
const result = parseAsciiDoc(doc); |
||||||
|
expect(result.html).toContain('<ul>'); |
||||||
|
expect(result.html).toContain('<ol>'); |
||||||
|
expect(result.html).toContain('nested'); |
||||||
|
expect(result.html).toContain('first'); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,175 @@ |
|||||||
|
import type { AsciiDocParserOptions, AsciiDocParseResult } from './types.js'; |
||||||
|
import { formatInline } from './inline.js'; |
||||||
|
|
||||||
|
/** Slug for heading id: lowercase, replace spaces with _, strip non-alnum */ |
||||||
|
function slug(title: string): string { |
||||||
|
return title |
||||||
|
.trim() |
||||||
|
.toLowerCase() |
||||||
|
.replace(/\s+/g, '_') |
||||||
|
.replace(/[^a-z0-9_-]/g, ''); |
||||||
|
} |
||||||
|
|
||||||
|
/** Escape HTML text content */ |
||||||
|
function escapeHtml(s: string): string { |
||||||
|
return s |
||||||
|
.replace(/&/g, '&') |
||||||
|
.replace(/</g, '<') |
||||||
|
.replace(/>/g, '>') |
||||||
|
.replace(/"/g, '"'); |
||||||
|
} |
||||||
|
|
||||||
|
interface TocEntry { |
||||||
|
level: number; |
||||||
|
title: string; |
||||||
|
id: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface ListItem { |
||||||
|
depth: number; |
||||||
|
ordered: boolean; |
||||||
|
text: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Lightweight AsciiDoc parser (no asciidoctor dependency). |
||||||
|
* Supports: headings =..======, paragraphs, #hashtags, nostr links, |
||||||
|
* wikilinks, URLs, *bold* _italic_ `code`, ~~strikethrough~~, ~sub~ ^sup^, nested/ordered lists. |
||||||
|
*/ |
||||||
|
export function parseAsciiDoc( |
||||||
|
content: string, |
||||||
|
options: AsciiDocParserOptions = {} |
||||||
|
): AsciiDocParseResult { |
||||||
|
const { toc = false } = options; |
||||||
|
const lines = content.split(/\r?\n/); |
||||||
|
const tocEntries: TocEntry[] = []; |
||||||
|
const blocks: string[] = []; |
||||||
|
let i = 0; |
||||||
|
|
||||||
|
function pushBlock(html: string): void { |
||||||
|
if (html) blocks.push(html); |
||||||
|
} |
||||||
|
|
||||||
|
function parseHeadingLevel(line: string): number { |
||||||
|
let n = 0; |
||||||
|
while (line[n] === '=' && n < 6) n++; |
||||||
|
if (n >= 1 && line[n] === ' ') return n; |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
function getListInfo(trimmed: string): { depth: number; ordered: boolean; text: string } | null { |
||||||
|
const ul = trimmed.match(/^(\*+)\s+(.*)$/); |
||||||
|
if (ul) return { depth: ul[1].length, ordered: false, text: ul[2] ?? '' }; |
||||||
|
const ol = trimmed.match(/^(\.+)\s+(.*)$/); |
||||||
|
if (ol) return { depth: ol[1].length, ordered: true, text: ol[2] ?? '' }; |
||||||
|
const dash = trimmed.match(/^(-\s+)(.*)$/); |
||||||
|
if (dash) return { depth: 1, ordered: false, text: dash[2] ?? '' }; |
||||||
|
const num = trimmed.match(/^(\d+\.)\s+(.*)$/); |
||||||
|
if (num) return { depth: 1, ordered: true, text: num[2] ?? '' }; |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
function flushParagraph(buf: string[]): void { |
||||||
|
if (buf.length === 0) return; |
||||||
|
const text = buf.join(' ').trim(); |
||||||
|
if (text) pushBlock('<p>' + formatInline(text, options) + '</p>'); |
||||||
|
} |
||||||
|
|
||||||
|
function flushListNested(items: ListItem[]): void { |
||||||
|
if (items.length === 0) return; |
||||||
|
const stack: { tag: string; depth: number }[] = []; |
||||||
|
const out: string[] = []; |
||||||
|
|
||||||
|
for (const it of items) { |
||||||
|
while (stack.length > 0 && stack[stack.length - 1].depth >= it.depth) { |
||||||
|
out.push('</' + stack.pop()!.tag + '>'); |
||||||
|
} |
||||||
|
const tag = it.ordered ? 'ol' : 'ul'; |
||||||
|
const needOpen = stack.length === 0 || stack[stack.length - 1].depth < it.depth; |
||||||
|
if (needOpen) { |
||||||
|
stack.push({ tag, depth: it.depth }); |
||||||
|
out.push('<' + tag + '>'); |
||||||
|
} |
||||||
|
out.push('<li>' + formatInline(it.text.trim(), options) + '</li>'); |
||||||
|
} |
||||||
|
while (stack.length > 0) out.push('</' + stack.pop()!.tag + '>'); |
||||||
|
pushBlock(out.join('')); |
||||||
|
} |
||||||
|
|
||||||
|
let paraBuf: string[] = []; |
||||||
|
let listItems: ListItem[] = []; |
||||||
|
|
||||||
|
while (i < lines.length) { |
||||||
|
const raw = lines[i]; |
||||||
|
const line = raw; |
||||||
|
const trimmed = line.trim(); |
||||||
|
|
||||||
|
if (trimmed === '') { |
||||||
|
flushParagraph(paraBuf); |
||||||
|
paraBuf = []; |
||||||
|
flushListNested(listItems); |
||||||
|
listItems = []; |
||||||
|
i++; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const level = parseHeadingLevel(line); |
||||||
|
if (level >= 1) { |
||||||
|
flushParagraph(paraBuf); |
||||||
|
paraBuf = []; |
||||||
|
flushListNested(listItems); |
||||||
|
listItems = []; |
||||||
|
const title = line.slice(level).trimStart(); |
||||||
|
const id = slug(title) || `_h${level}`; |
||||||
|
if (toc) tocEntries.push({ level, title, id }); |
||||||
|
pushBlock(`<h${level} id="${escapeHtml(id)}">${formatInline(title, options)}</h${level}>`); |
||||||
|
i++; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const listInfo = getListInfo(trimmed); |
||||||
|
if (listInfo) { |
||||||
|
flushParagraph(paraBuf); |
||||||
|
paraBuf = []; |
||||||
|
listItems.push(listInfo); |
||||||
|
i++; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (listItems.length > 0 && /^\s/.test(line)) { |
||||||
|
listItems[listItems.length - 1].text += ' ' + trimmed; |
||||||
|
i++; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
flushListNested(listItems); |
||||||
|
listItems = []; |
||||||
|
paraBuf.push(trimmed); |
||||||
|
i++; |
||||||
|
} |
||||||
|
|
||||||
|
flushParagraph(paraBuf); |
||||||
|
flushListNested(listItems); |
||||||
|
|
||||||
|
let tableOfContents = ''; |
||||||
|
if (toc && tocEntries.length > 0) { |
||||||
|
const tocList = tocEntries |
||||||
|
.map((e) => `<li><a href="#${escapeHtml(e.id)}">${formatInline(e.title, options)}</a></li>`) |
||||||
|
.join(''); |
||||||
|
tableOfContents = '<ul class="toc">' + tocList + '</ul>'; |
||||||
|
} |
||||||
|
|
||||||
|
const html = blocks.join('\n'); |
||||||
|
return { html, tableOfContents }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render a single event's content (e.g. kind 30818 or 30041) as HTML. |
||||||
|
* Use this for individual wiki or section events. |
||||||
|
*/ |
||||||
|
export function renderEventContent( |
||||||
|
content: string, |
||||||
|
options: AsciiDocParserOptions = {} |
||||||
|
): string { |
||||||
|
return parseAsciiDoc(content, options).html; |
||||||
|
} |
||||||
@ -0,0 +1,157 @@ |
|||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
import { |
||||||
|
buildPublicationAsciiDoc, |
||||||
|
renderPublication, |
||||||
|
renderPublicationFromEvents, |
||||||
|
renderEvent, |
||||||
|
toPublicationIndexLike, |
||||||
|
} from './publication-helper.js'; |
||||||
|
|
||||||
|
const index = { |
||||||
|
kind: 30040 as const, |
||||||
|
title: 'My Book', |
||||||
|
d: 'my-book-v1', |
||||||
|
a: ['30041:abc:ch1', '30041:abc:ch2'], |
||||||
|
type: 'book', |
||||||
|
}; |
||||||
|
|
||||||
|
const sections = [ |
||||||
|
{ address: '30041:abc:ch1', title: 'Chapter One', content: 'First chapter body.' }, |
||||||
|
{ address: '30041:abc:ch2', title: 'Chapter Two', content: 'Second chapter body.' }, |
||||||
|
]; |
||||||
|
|
||||||
|
describe('buildPublicationAsciiDoc', () => { |
||||||
|
it('builds combined AsciiDoc from index and sections', () => { |
||||||
|
const adoc = buildPublicationAsciiDoc({ index, sections }); |
||||||
|
expect(adoc).toContain('= My Book'); |
||||||
|
expect(adoc).toContain('== Chapter One'); |
||||||
|
expect(adoc).toContain('First chapter body.'); |
||||||
|
expect(adoc).toContain('== Chapter Two'); |
||||||
|
expect(adoc).toContain('Second chapter body.'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws if kind is not 30040', () => { |
||||||
|
expect(() => |
||||||
|
buildPublicationAsciiDoc({ |
||||||
|
index: { ...index, kind: 1 as 30040 }, |
||||||
|
sections, |
||||||
|
}) |
||||||
|
).toThrow('Expected kind 30040'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('renderPublication', () => { |
||||||
|
it('returns asciidoc, html, and tableOfContents', () => { |
||||||
|
const out = renderPublication({ index, sections }); |
||||||
|
expect(out.asciidoc).toContain('= My Book'); |
||||||
|
expect(out.html).toContain('My Book'); |
||||||
|
expect(out.html).toContain('Chapter One'); |
||||||
|
expect(out.html).toContain('First chapter body.'); |
||||||
|
expect(typeof out.tableOfContents).toBe('string'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('can disable toc', () => { |
||||||
|
const out = renderPublication({ index, sections }, { toc: false }); |
||||||
|
expect(out.tableOfContents).toBe(''); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('toPublicationIndexLike', () => { |
||||||
|
it('parses event-like payload with kind 30040 and title', () => { |
||||||
|
const payload = { |
||||||
|
kind: 30040, |
||||||
|
tags: [ |
||||||
|
['title', 'Test Pub'], |
||||||
|
['d', 'test-d'], |
||||||
|
['a', '30041:pk1:d1'], |
||||||
|
['a', '30041:pk1:d2'], |
||||||
|
['type', 'blog'], |
||||||
|
['image', 'https://example.com/cover.png'], |
||||||
|
], |
||||||
|
}; |
||||||
|
const parsed = toPublicationIndexLike(payload); |
||||||
|
expect(parsed).not.toBeNull(); |
||||||
|
expect(parsed!.kind).toBe(30040); |
||||||
|
expect(parsed!.title).toBe('Test Pub'); |
||||||
|
expect(parsed!.d).toBe('test-d'); |
||||||
|
expect(parsed!.a).toEqual(['30041:pk1:d1', '30041:pk1:d2']); |
||||||
|
expect(parsed!.type).toBe('blog'); |
||||||
|
expect(parsed!.image).toBe('https://example.com/cover.png'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns null for wrong kind', () => { |
||||||
|
expect(toPublicationIndexLike({ kind: 1, tags: [['title', 'X']] })).toBeNull(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns null when title is missing', () => { |
||||||
|
expect(toPublicationIndexLike({ kind: 30040, tags: [['d', 'x']] })).toBeNull(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('renderEvent', () => { |
||||||
|
it('accepts event JSON and returns HTML', () => { |
||||||
|
const event = { |
||||||
|
kind: 30041, |
||||||
|
content: '= One section\n\nSome *bold* text.', |
||||||
|
tags: [['title', 'One section']], |
||||||
|
pubkey: 'abc', |
||||||
|
}; |
||||||
|
const html = renderEvent(event); |
||||||
|
expect(html).toContain('<h1'); |
||||||
|
expect(html).toContain('One section'); |
||||||
|
expect(html).toContain('<strong>bold</strong>'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('accepts 30818 wiki event', () => { |
||||||
|
const event = { kind: 30818, content: 'Wiki *content*.', tags: [] }; |
||||||
|
const html = renderEvent(event); |
||||||
|
expect(html).toContain('Wiki'); |
||||||
|
expect(html).toContain('<strong>content</strong>'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('renderPublicationFromEvents', () => { |
||||||
|
it('accepts index event + section events JSON and returns one HTML page', () => { |
||||||
|
const indexEvent = { |
||||||
|
kind: 30040, |
||||||
|
content: '', |
||||||
|
tags: [ |
||||||
|
['title', 'My Book'], |
||||||
|
['d', 'my-book'], |
||||||
|
['a', '30041:pk:ch1'], |
||||||
|
['a', '30041:pk:ch2'], |
||||||
|
], |
||||||
|
pubkey: 'pk', |
||||||
|
}; |
||||||
|
const sectionEvents = [ |
||||||
|
{ |
||||||
|
kind: 30041, |
||||||
|
content: 'First chapter body.', |
||||||
|
tags: [['title', 'Chapter One'], ['d', 'ch1']], |
||||||
|
pubkey: 'pk', |
||||||
|
}, |
||||||
|
{ |
||||||
|
kind: 30041, |
||||||
|
content: 'Second chapter body.', |
||||||
|
tags: [['title', 'Chapter Two'], ['d', 'ch2']], |
||||||
|
pubkey: 'pk', |
||||||
|
}, |
||||||
|
]; |
||||||
|
const out = renderPublicationFromEvents(indexEvent, sectionEvents); |
||||||
|
expect(out.html).toContain('My Book'); |
||||||
|
expect(out.html).toContain('Chapter One'); |
||||||
|
expect(out.html).toContain('First chapter body.'); |
||||||
|
expect(out.html).toContain('Chapter Two'); |
||||||
|
expect(out.html).toContain('Second chapter body.'); |
||||||
|
expect(out.asciidoc).toContain('= My Book'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('throws if index event is not kind 30040', () => { |
||||||
|
expect(() => |
||||||
|
renderPublicationFromEvents( |
||||||
|
{ kind: 1, content: '', tags: [['title', 'X']] }, |
||||||
|
[] |
||||||
|
) |
||||||
|
).toThrow('Event kind must be 30040'); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
import { parseAsciiDoc } from './parser.js'; |
||||||
|
import type { |
||||||
|
PublicationInput, |
||||||
|
PublicationRenderResult, |
||||||
|
PublicationIndexLike, |
||||||
|
PublicationSection, |
||||||
|
NostrEventLike, |
||||||
|
} from './types.js'; |
||||||
|
import type { AsciiDocParserOptions } from './types.js'; |
||||||
|
|
||||||
|
const PUBLICATION_KIND = 30040; |
||||||
|
const SECTION_KINDS = [30041, 30818]; |
||||||
|
|
||||||
|
function getTag(event: NostrEventLike, name: string): string | undefined { |
||||||
|
return event.tags.find((t) => t[0] === name)?.[1]; |
||||||
|
} |
||||||
|
|
||||||
|
function eventAddress(event: NostrEventLike): string { |
||||||
|
const d = getTag(event, 'd') ?? ''; |
||||||
|
const pubkey = event.pubkey ?? ''; |
||||||
|
return `${event.kind}:${pubkey}:${d}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build combined AsciiDoc for a kind 30040 publication from index + sections. |
||||||
|
*/ |
||||||
|
export function buildPublicationAsciiDoc(input: PublicationInput): string { |
||||||
|
const { index, sections } = input; |
||||||
|
if (index.kind !== PUBLICATION_KIND) { |
||||||
|
throw new Error(`Expected kind ${PUBLICATION_KIND}, got ${index.kind}`); |
||||||
|
} |
||||||
|
|
||||||
|
const lines: string[] = []; |
||||||
|
lines.push(`= ${index.title}`); |
||||||
|
lines.push(''); |
||||||
|
|
||||||
|
for (const section of sections) { |
||||||
|
lines.push(`== ${section.title}`); |
||||||
|
lines.push(''); |
||||||
|
lines.push(section.content.trim()); |
||||||
|
lines.push(''); |
||||||
|
} |
||||||
|
|
||||||
|
return lines.join('\n').trimEnd(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render a kind 30040 publication to HTML: build combined AsciiDoc, then parse. |
||||||
|
*/ |
||||||
|
export function renderPublication( |
||||||
|
input: PublicationInput, |
||||||
|
options: { toc?: boolean } & AsciiDocParserOptions = {} |
||||||
|
): PublicationRenderResult { |
||||||
|
const { toc = true, ...parserOptions } = options; |
||||||
|
const asciidoc = buildPublicationAsciiDoc(input); |
||||||
|
const { html, tableOfContents } = parseAsciiDoc(asciidoc, { toc, ...parserOptions }); |
||||||
|
return { asciidoc, html, tableOfContents }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Normalize a Nostr-like event to PublicationIndexLike (for use with raw event payloads). |
||||||
|
*/ |
||||||
|
export function toPublicationIndexLike(payload: { |
||||||
|
kind: number; |
||||||
|
tags: string[][]; |
||||||
|
}): PublicationIndexLike | null { |
||||||
|
if (payload.kind !== PUBLICATION_KIND) return null; |
||||||
|
|
||||||
|
const tag = (name: string): string | undefined => |
||||||
|
payload.tags.find((t) => t[0] === name)?.[1]; |
||||||
|
|
||||||
|
const title = tag('title'); |
||||||
|
if (!title) return null; |
||||||
|
|
||||||
|
const aTags = payload.tags.filter((t) => t[0] === 'a').map((t) => t[1]); |
||||||
|
return { |
||||||
|
kind: PUBLICATION_KIND, |
||||||
|
title, |
||||||
|
d: tag('d'), |
||||||
|
a: aTags, |
||||||
|
type: tag('type'), |
||||||
|
image: tag('image'), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render a single Nostr event (e.g. kind 30818 or 30041) as HTML. |
||||||
|
* Accepts event as JSON (kind, content, tags, optional pubkey/id). |
||||||
|
*/ |
||||||
|
export function renderEvent( |
||||||
|
event: NostrEventLike, |
||||||
|
options: AsciiDocParserOptions & { toc?: boolean } = {} |
||||||
|
): string { |
||||||
|
const { toc = false, ...parserOptions } = options; |
||||||
|
const result = parseAsciiDoc(event.content, { ...parserOptions, toc }); |
||||||
|
return result.html; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render a kind 30040 publication from one index event and its section events (30041, 30818) as JSON. |
||||||
|
* Returns one HTML page plus TOC and combined AsciiDoc. |
||||||
|
*/ |
||||||
|
export function renderPublicationFromEvents( |
||||||
|
indexEvent: NostrEventLike, |
||||||
|
sectionEvents: NostrEventLike[], |
||||||
|
options: AsciiDocParserOptions & { toc?: boolean } = {} |
||||||
|
): PublicationRenderResult { |
||||||
|
const index = toPublicationIndexLike(indexEvent); |
||||||
|
if (!index) throw new Error(`Event kind must be ${PUBLICATION_KIND}, got ${indexEvent.kind}`); |
||||||
|
|
||||||
|
const sections: PublicationSection[] = sectionEvents |
||||||
|
.filter((e) => SECTION_KINDS.includes(e.kind)) |
||||||
|
.map((e) => ({ |
||||||
|
address: eventAddress(e), |
||||||
|
title: getTag(e, 'title') || eventAddress(e), |
||||||
|
content: e.content, |
||||||
|
})); |
||||||
|
|
||||||
|
return renderPublication({ index, sections }, options); |
||||||
|
} |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
/** |
||||||
|
* Minimal Nostr event shape (JSON from relay or NIP-01). |
||||||
|
* Used as input to renderEvent and renderPublicationFromEvents. |
||||||
|
*/ |
||||||
|
export interface NostrEventLike { |
||||||
|
kind: number; |
||||||
|
content: string; |
||||||
|
tags: string[][]; |
||||||
|
pubkey?: string; |
||||||
|
id?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Options for the AsciiDoc parser. |
||||||
|
*/ |
||||||
|
export interface AsciiDocParserOptions { |
||||||
|
/** Include table of contents in output (default: false) */ |
||||||
|
toc?: boolean; |
||||||
|
/** Section numbering (default: false) */ |
||||||
|
sectnum?: boolean; |
||||||
|
/** URL template for wikilinks; {dtag} is replaced by the link target (default: "#") */ |
||||||
|
wikilinkUrl?: string | ((dtag: string) => string); |
||||||
|
/** If set, nostr addresses are turned into links with this base (e.g. "https://app.example.com/"); empty = use nostr: as href */ |
||||||
|
nostrBaseUrl?: string; |
||||||
|
/** CSS class for hashtag spans (default: "hashtag") */ |
||||||
|
hashtagClass?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Result of parsing AsciiDoc to HTML. |
||||||
|
*/ |
||||||
|
export interface AsciiDocParseResult { |
||||||
|
/** Rendered HTML content */ |
||||||
|
html: string; |
||||||
|
/** Extracted table of contents HTML fragment, if toc was requested */ |
||||||
|
tableOfContents: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Kind 30040 publication index (Nostr replaceable event). |
||||||
|
* Tag names are lowercase; values are from getMatchingTags-style arrays. |
||||||
|
*/ |
||||||
|
export interface PublicationIndexLike { |
||||||
|
kind: 30040; |
||||||
|
/** e.g. from "title" tag */ |
||||||
|
title: string; |
||||||
|
/** e.g. from "d" tag - identifier for the replaceable event */ |
||||||
|
d?: string; |
||||||
|
/** References to child events/sections: "kind:pubkey:d" (e.g. "30041:...:...") */ |
||||||
|
a: string[]; |
||||||
|
/** Optional "type" tag (e.g. "blog", "book") */ |
||||||
|
type?: string; |
||||||
|
/** Optional "image" tag (cover image URL) */ |
||||||
|
image?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* A single section in a publication (e.g. kind 30041 content event). |
||||||
|
*/ |
||||||
|
export interface PublicationSection { |
||||||
|
/** Address or id for this section (e.g. "30041:pubkey:d") */ |
||||||
|
address: string; |
||||||
|
/** Section title (from "title" tag or first heading) */ |
||||||
|
title: string; |
||||||
|
/** Raw AsciiDoc content of the section */ |
||||||
|
content: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Input to the publication helper: index metadata + resolved sections. |
||||||
|
*/ |
||||||
|
export interface PublicationInput { |
||||||
|
index: PublicationIndexLike; |
||||||
|
/** Ordered list of section contents (one per "a" ref, or merged) */ |
||||||
|
sections: PublicationSection[]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Rendered publication output. |
||||||
|
*/ |
||||||
|
export interface PublicationRenderResult { |
||||||
|
/** Single combined AsciiDoc source (title + all sections) */ |
||||||
|
asciidoc: string; |
||||||
|
/** Rendered HTML for the full publication */ |
||||||
|
html: string; |
||||||
|
/** Table of contents HTML fragment */ |
||||||
|
tableOfContents: string; |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "ES2022", |
||||||
|
"module": "NodeNext", |
||||||
|
"moduleResolution": "NodeNext", |
||||||
|
"lib": ["ES2022"], |
||||||
|
"outDir": "dist", |
||||||
|
"rootDir": "src", |
||||||
|
"strict": true, |
||||||
|
"declaration": true, |
||||||
|
"declarationMap": true, |
||||||
|
"sourceMap": true, |
||||||
|
"skipLibCheck": true |
||||||
|
}, |
||||||
|
"include": ["src/**/*.ts"], |
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"] |
||||||
|
} |
||||||
Loading…
Reference in new issue