commit
af417c5d7d
17 changed files with 3443 additions and 0 deletions
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
node_modules |
||||
dist |
||||
integration-output |
||||
@ -0,0 +1,75 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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