Browse Source

initial commit

main
Silberengel 9 hours ago
commit
af417c5d7d
  1. 3
      .gitignore
  2. 75
      README.md
  3. 519
      asciidoc_testdoc.adoc
  4. 184
      integration-fixtures/publication.json
  5. 60
      integration-fixtures/single-event.json
  6. 1568
      package-lock.json
  7. 35
      package.json
  8. 17
      src/index.ts
  9. 134
      src/inline.ts
  10. 167
      src/integration.test.ts
  11. 111
      src/parser.test.ts
  12. 175
      src/parser.ts
  13. 157
      src/publication-helper.test.ts
  14. 120
      src/publication-helper.ts
  15. 88
      src/types.ts
  16. 17
      tsconfig.json
  17. 13
      vitest.config.ts

3
.gitignore vendored

@ -0,0 +1,3 @@
node_modules
dist
integration-output

75
README.md

@ -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.

519
asciidoc_testdoc.adoc

@ -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 ...
....

184
integration-fixtures/publication.json

@ -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"
}
]
}

60
integration-fixtures/single-event.json

File diff suppressed because one or more lines are too long

1568
package-lock.json generated

File diff suppressed because it is too large Load Diff

35
package.json

@ -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"
}
}

17
src/index.ts

@ -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';

134
src/inline.ts

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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;
}

167
src/integration.test.ts

@ -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);
});

111
src/parser.test.ts

@ -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');
});
});

175
src/parser.ts

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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;
}

157
src/publication-helper.test.ts

@ -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');
});
});

120
src/publication-helper.ts

@ -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);
}

88
src/types.ts

@ -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;
}

17
tsconfig.json

@ -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"]
}

13
vitest.config.ts

@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
globals: false,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});
Loading…
Cancel
Save