diff --git a/deno.json b/deno.json index 4db64560..03bab766 100644 --- a/deno.json +++ b/deno.json @@ -34,7 +34,7 @@ "@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", "@nostrify/db": "jsr:@nostrify/db@^0.31.2", - "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.30.0", + "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.30.1", "@scure/base": "npm:@scure/base@^1.1.6", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0", @@ -50,6 +50,7 @@ "@std/streams": "jsr:@std/streams@^0.223.0", "comlink": "npm:comlink@^4.4.1", "comlink-async-generator": "npm:comlink-async-generator@^0.0.1", + "commander": "npm:commander@12.1.0", "deno-safe-fetch/load": "https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/load.ts", "deno.json": "./deno.json", "entities": "npm:entities@^4.5.0", diff --git a/deno.lock b/deno.lock index 68c03b53..cb040522 100644 --- a/deno.lock +++ b/deno.lock @@ -12,13 +12,14 @@ "jsr:@gleasonator/policy@0.4.0": "jsr:@gleasonator/policy@0.4.0", "jsr:@gleasonator/policy@0.4.1": "jsr:@gleasonator/policy@0.4.1", "jsr:@gleasonator/policy@0.4.2": "jsr:@gleasonator/policy@0.4.2", - "jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.5", + "jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.9", "jsr:@lambdalisue/async@^2.1.1": "jsr:@lambdalisue/async@2.1.1", "jsr:@nostrify/db@^0.31.2": "jsr:@nostrify/db@0.31.2", "jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5", "jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4", "jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5", - "jsr:@nostrify/nostrify@^0.30.0": "jsr:@nostrify/nostrify@0.30.0", + "jsr:@nostrify/nostrify@^0.30.0": "jsr:@nostrify/nostrify@0.30.1", + "jsr:@nostrify/nostrify@^0.30.1": "jsr:@nostrify/nostrify@0.30.1", "jsr:@nostrify/types@^0.30.0": "jsr:@nostrify/types@0.30.0", "jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0", "jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0", @@ -30,6 +31,7 @@ "jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0", "jsr:@std/bytes@^1.0.0-rc.3": "jsr:@std/bytes@1.0.0", "jsr:@std/bytes@^1.0.1-rc.3": "jsr:@std/bytes@1.0.2", + "jsr:@std/bytes@^1.0.2": "jsr:@std/bytes@1.0.2", "jsr:@std/bytes@^1.0.2-rc.3": "jsr:@std/bytes@1.0.2", "jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0", "jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2", @@ -43,7 +45,7 @@ "jsr:@std/fs@^0.221.0": "jsr:@std/fs@0.221.0", "jsr:@std/fs@^0.229.3": "jsr:@std/fs@0.229.3", "jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.1", - "jsr:@std/io@^0.224": "jsr:@std/io@0.224.4", + "jsr:@std/io@^0.224": "jsr:@std/io@0.224.6", "jsr:@std/json@^0.223.0": "jsr:@std/json@0.223.0", "jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1", "jsr:@std/path@0.213.1": "jsr:@std/path@0.213.1", @@ -60,6 +62,7 @@ "npm:comlink-async-generator": "npm:comlink-async-generator@0.0.1", "npm:comlink-async-generator@^0.0.1": "npm:comlink-async-generator@0.0.1", "npm:comlink@^4.4.1": "npm:comlink@4.4.1", + "npm:commander@12.1.0": "npm:commander@12.1.0", "npm:entities@^4.5.0": "npm:entities@4.5.0", "npm:fast-stable-stringify@^1.0.0": "npm:fast-stable-stringify@1.0.0", "npm:formdata-helper@^0.3.0": "npm:formdata-helper@0.3.0", @@ -171,6 +174,9 @@ "@hono/hono@4.5.5": { "integrity": "e5a63b5f535475cd80974b65fed23a138d0cbb91fe1cc9a17a7c7278e835c308" }, + "@hono/hono@4.5.9": { + "integrity": "47f561e67aedbd6d1e21e3a1ae26c1b80ffdb62a51c161d502e75bee17ca40af" + }, "@lambdalisue/async@2.1.1": { "integrity": "1fc9bc6f4ed50215cd2f7217842b18cea80f81c25744f88f8c5eb4be5a1c9ab4" }, @@ -227,6 +233,21 @@ "npm:zod@^3.23.8" ] }, + "@nostrify/nostrify@0.30.1": { + "integrity": "fcc923707e87a9fbecc82dbb18756d1d3d134cd0763f4b1254c4bce709e811eb", + "dependencies": [ + "jsr:@nostrify/types@^0.30.0", + "jsr:@std/crypto@^0.224.0", + "jsr:@std/encoding@^0.224.1", + "npm:@scure/base@^1.1.6", + "npm:@scure/bip32@^1.4.0", + "npm:@scure/bip39@^1.3.0", + "npm:lru-cache@^10.2.0", + "npm:nostr-tools@^2.7.0", + "npm:websocket-ts@^2.1.5", + "npm:zod@^3.23.8" + ] + }, "@nostrify/types@0.30.0": { "integrity": "1f38fa849cff930bd709edbf94ef9ac02f46afb8b851f86c8736517b354616da" }, @@ -341,6 +362,12 @@ "jsr:@std/bytes@^1.0.2-rc.3" ] }, + "@std/io@0.224.6": { + "integrity": "eefe034a370be34daf066c8634dd645635d099bb21eccf110f0bdc28d9040891", + "dependencies": [ + "jsr:@std/bytes@^1.0.2" + ] + }, "@std/json@0.223.0": { "integrity": "9a4a255931dd0397924c6b10bb6a72fe3e28ddd876b981ada2e3b8dd0764163f" }, @@ -543,6 +570,10 @@ "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "dependencies": {} }, + "commander@12.1.0": { + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dependencies": {} + }, "cross-spawn@7.0.3": { "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dependencies": { @@ -1887,7 +1918,7 @@ "jsr:@hono/hono@^4.4.6", "jsr:@lambdalisue/async@^2.1.1", "jsr:@nostrify/db@^0.31.2", - "jsr:@nostrify/nostrify@^0.30.0", + "jsr:@nostrify/nostrify@^0.30.1", "jsr:@soapbox/kysely-deno-sqlite@^2.1.0", "jsr:@soapbox/stickynotes@^0.4.0", "jsr:@std/assert@^0.225.1", @@ -1904,6 +1935,7 @@ "npm:@scure/base@^1.1.6", "npm:comlink-async-generator@^0.0.1", "npm:comlink@^4.4.1", + "npm:commander@12.1.0", "npm:entities@^4.5.0", "npm:fast-stable-stringify@^1.0.0", "npm:formdata-helper@^0.3.0", diff --git a/installation/ditto.conf b/installation/ditto.conf index 9eddebf3..f92e7e39 100644 --- a/installation/ditto.conf +++ b/installation/ditto.conf @@ -42,11 +42,6 @@ server { try_files $uri =404; } - location = /favicon.ico { - root /opt/ditto/static; - try_files $uri =404; - } - location /metrics { allow 127.0.0.1; deny all; diff --git a/scripts/db-export.test.ts b/scripts/db-export.test.ts new file mode 100644 index 00000000..939537d5 --- /dev/null +++ b/scripts/db-export.test.ts @@ -0,0 +1,94 @@ +import { assertEquals, assertThrows } from '@std/assert'; +import { buildFilter } from './db-export.ts'; + +Deno.test('buildFilter should return an empty filter when no arguments are provided', () => { + const filter = buildFilter({}); + assertEquals(Object.keys(filter).length, 0); +}); + +Deno.test('buildFilter should correctly handle valid authors', () => { + const filter = buildFilter({ + authors: ['a'.repeat(64)], + }); + + assertEquals(filter.authors, ['a'.repeat(64)]); +}); + +Deno.test('buildFilter throws on invalid author pubkey', () => { + assertThrows( + () => { + buildFilter({ + authors: ['invalid_pubkey'], + }); + }, + Error, + 'ERROR: Invalid pubkey invalid_pubkey supplied.', + ); +}); + +Deno.test('buildFilter should correctly handle valid ids', () => { + const filter = buildFilter({ + ids: ['b'.repeat(64)], + }); + + assertEquals(filter.ids, ['b'.repeat(64)]); +}); + +Deno.test('buildFilter should throw on invalid event IDs', () => { + assertThrows( + () => { + buildFilter({ + ids: ['invalid_id'], + }); + }, + Error, + 'ERROR: Invalid event ID invalid_id supplied.', + ); +}); + +Deno.test('buildFilter should correctly handle tag shortcuts', () => { + const filter = buildFilter({ + d: 'value1', + e: 'a'.repeat(64), + p: 'b'.repeat(64), + }); + + assertEquals(filter['#d'], ['value1']); + assertEquals(filter['#e'], ['a'.repeat(64)]); + assertEquals(filter['#p'], ['b'.repeat(64)]); +}); + +Deno.test('buildFilter should correctly handle since and until args', () => { + const filter = buildFilter({ + since: 1000, + until: 2000, + }); + + assertEquals(filter.since, 1000); + assertEquals(filter.until, 2000); +}); + +Deno.test('buildFilter should correctly handle search field', () => { + const filter = buildFilter({ + search: 'search_term', + }); + + assertEquals(filter.search, 'search_term'); +}); + +Deno.test('buildFilter should correctly handle tag k-v pairs', () => { + const filter = buildFilter({ + tags: ['tag1=value1', 'tag2=value2'], + }); + + assertEquals(filter['#tag1'], ['value1']); + assertEquals(filter['#tag2'], ['value2']); +}); + +Deno.test('buildFilter should correctly handle limit specifier', () => { + const filter = buildFilter({ + limit: 10, + }); + + assertEquals(filter.limit, 10); +}); diff --git a/scripts/db-export.ts b/scripts/db-export.ts index fbdac1b7..71939105 100644 --- a/scripts/db-export.ts +++ b/scripts/db-export.ts @@ -1,24 +1,158 @@ import { Storages } from '@/storages.ts'; +import { NostrFilter } from '@nostrify/nostrify'; +import { Command, InvalidOptionArgumentError } from 'commander'; -const store = await Storages.db(); - -console.warn('Exporting events...'); - -let count = 0; - -for await (const msg of store.req([{}])) { - if (msg[0] === 'EOSE') { - break; - } - if (msg[0] === 'EVENT') { - console.log(JSON.stringify(msg[2])); - count++; - } - if (msg[0] === 'CLOSED') { - console.error('Database closed unexpectedly'); - break; - } +interface ExportFilter { + authors?: string[]; + ids?: string[]; + kinds?: number[]; + limit?: number; + search?: string; + /** + * Array of `key=value` pairs. + */ + tags?: string[]; + since?: number; + until?: number; + /** + * shortcut for `--tag d=` + */ + d?: string; + /** + * shortcut for `--tag e=` + */ + e?: string; + /** + * shortcut for `--tag p=` + */ + p?: string; } -console.warn(`Exported ${count} events`); -Deno.exit(); +function safeParseInt(s: string) { + const n = parseInt(s); + if (isNaN(n)) throw new InvalidOptionArgumentError('Not a number.'); + return n; +} + +function findInvalid(arr: string[], predicate = (v: string) => !/[a-f0-9]{64}/.test(v)) { + return arr.find(predicate); +} + +function die(code: number, ...args: any[]) { + console.error(...args); + Deno.exit(code); +} + +function tagFilterShortcut(name: 'd' | 'e' | 'p', value: string) { + const val = [value]; + if (findInvalid(val)) throw new Error(`ERROR: Invalid value supplied for ${name}-tag.`); + return val; +} + +export function buildFilter(args: ExportFilter) { + const filter: NostrFilter = {}; + const { authors, ids, kinds, d, e, limit, p, search, since, until, tags } = args; + if (since) { + filter.since = since; + } + if (until) { + filter.until = until; + } + if (authors && authors.length) { + const invalid = findInvalid(authors); + if (invalid) throw new Error(`ERROR: Invalid pubkey ${invalid} supplied.`); + filter.authors = authors; + } + if (ids) { + const invalid = findInvalid(ids); + if (invalid) throw new Error(`ERROR: Invalid event ID ${invalid} supplied.`); + filter.ids = ids; + } + if (kinds && kinds.length) { + filter.kinds = kinds; + } + if (d) { + filter['#d'] = [d]; + } + if (e) { + filter['#e'] = tagFilterShortcut('e', e); + } + if (p) { + filter['#p'] = tagFilterShortcut('e', p); + } + if (search) { + filter.search = search; + } + if (limit) { + filter.limit = limit; + } + if (tags) { + for (const val of tags) { + const [name, ...values] = val.split('='); + filter[`#${name}`] = [values.join('=')]; + } + } + + return filter; +} + +async function exportEvents(args: ExportFilter) { + const store = await Storages.db(); + + let filter: NostrFilter = {}; + try { + filter = buildFilter(args); + } catch (e) { + die(1, e.message || e.toString()); + } + + let count = 0; + for await (const msg of store.req([filter])) { + if (msg[0] === 'EOSE') { + break; + } + if (msg[0] === 'EVENT') { + console.log(JSON.stringify(msg[2])); + count++; + } + if (msg[0] === 'CLOSED') { + console.error('Database closed unexpectedly'); + break; + } + } + + console.warn(`Exported ${count} events`); +} + +if (import.meta.main) { + const exporter = new Command() + .name('db:export') + .description('Export the specified set of events from the Ditto database, in JSONL format.') + .version('0.1.0') + .showHelpAfterError(); + + exporter + .option('-a, --authors ', 'Pubkeys of authors whose events you want to export.', []) + .option('-i, --ids ', 'IDs of events you want to export.', []) + .option( + '-k --kinds ', + 'Event kinds you want to export.', + (v: string, arr: number[]) => arr.concat([safeParseInt(v)]), + [], + ) + .option( + '-t --tags ', + 'A list of key=value pairs of tags to search for events using. For tag values with spaces etc, simply quote the entire item, like `deno task db:export -t "name=A string with spaces in it"`.', + [], + ) + .option('--search ', 'A string to full-text search the db for.') + .option('-s --since ', 'The oldest time an exported event should be from.', safeParseInt) + .option('-u --until ', 'The newest time an exported event should be from.', safeParseInt) + .option('--limit ', 'Maximum number of events to export.', safeParseInt) + .option('-d ', 'Shortcut for `--tag d=`.') + .option('-e ', 'Shortcut for `--tag e=`.') + .option('-p ', 'Shortcut for `--tag p=`.') + .action(exportEvents); + + await exporter.parseAsync(Deno.args, { from: 'user' }); +} diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 899456a4..b9be027f 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -276,10 +276,12 @@ export const statusZapSplitsController: AppController = async (c) => { const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0; + const message = zapsTag.find((name) => name[1] === pubkey)![4] ?? ''; + return { account, - message: '', - weight: weight, + message, + weight, }; }))).filter((zapSplit) => zapSplit.weight > 0); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 0b910369..621e26a8 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -177,7 +177,7 @@ const createStatusController: AppController = async (c) => { let totalSplit = 0; for (const pubkey in dittoZapSplit) { totalSplit += dittoZapSplit[pubkey].weight; - tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString()]); + tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString(), dittoZapSplit[pubkey].message]); } if (totalSplit) { tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]); diff --git a/src/metrics.ts b/src/metrics.ts index e774eedc..c1fb8238 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -9,7 +9,7 @@ export const httpRequestCounter = new Counter({ export const httpResponseCounter = new Counter({ name: 'http_responses_total', help: 'Total number of HTTP responses', - labelNames: ['status', 'path'], + labelNames: ['method', 'path', 'status'], }); export const streamingConnectionsGauge = new Gauge({ diff --git a/src/middleware/metricsMiddleware.ts b/src/middleware/metricsMiddleware.ts index f740a745..6cf0e6de 100644 --- a/src/middleware/metricsMiddleware.ts +++ b/src/middleware/metricsMiddleware.ts @@ -16,5 +16,5 @@ export const metricsMiddleware: MiddlewareHandler = async (c, next) => { // Get a parameterized path name like `/posts/:id` instead of `/posts/1234`. // Tries to find actual route names first before falling back on potential middleware handlers like `app.use('*')`. const path = c.req.matchedRoutes.find((r) => r.method !== 'ALL')?.path ?? c.req.routePath; - httpResponseCounter.inc({ status, path }); + httpResponseCounter.inc({ method, status, path }); }; diff --git a/src/utils/favicon.ts b/src/utils/favicon.ts index 569a1e35..1fd0640a 100644 --- a/src/utils/favicon.ts +++ b/src/utils/favicon.ts @@ -1,4 +1,4 @@ -import { DOMParser } from '@b-fuze/deno-dom/native'; +import { DOMParser } from '@b-fuze/deno-dom'; import Debug from '@soapbox/stickynotes/debug'; import tldts from 'tldts'; diff --git a/src/utils/note.test.ts b/src/utils/note.test.ts index 0c9c6bf8..39be190b 100644 --- a/src/utils/note.test.ts +++ b/src/utils/note.test.ts @@ -10,6 +10,45 @@ Deno.test('parseNoteContent', () => { assertEquals(firstUrl, undefined); }); +Deno.test('parseNoteContent parses URLs', () => { + const { html } = parseNoteContent('check out my website: https://alexgleason.me', []); + assertEquals(html, 'check out my website: https://alexgleason.me'); +}); + +Deno.test('parseNoteContent parses bare URLs', () => { + const { html } = parseNoteContent('have you seen ditto.pub?', []); + assertEquals(html, 'have you seen ditto.pub?'); +}); + +Deno.test('parseNoteContent parses mentions with apostrophes', () => { + const { html } = parseNoteContent( + `did you see nostr:nprofile1qqsqgc0uhmxycvm5gwvn944c7yfxnnxm0nyh8tt62zhrvtd3xkj8fhgprdmhxue69uhkwmr9v9ek7mnpw3hhytnyv4mz7un9d3shjqgcwaehxw309ahx7umywf5hvefwv9c8qtmjv4kxz7gpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7s3al0v's speech?`, + [{ + id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', + username: 'alex', + acct: 'alex@gleasonator.dev', + url: 'https://gleasonator.dev/@alex', + }], + ); + assertEquals( + html, + 'did you see @alex@gleasonator.dev's speech?', + ); +}); + +Deno.test("parseNoteContent doesn't parse invalid nostr URIs", () => { + const { html } = parseNoteContent('nip19 has URIs like nostr:npub and nostr:nevent, etc.', []); + assertEquals(html, 'nip19 has URIs like nostr:npub and nostr:nevent, etc.'); +}); + +Deno.test('parseNoteContent renders empty for non-profile nostr URIs', () => { + const { html } = parseNoteContent( + 'nostr:nevent1qgsr9cvzwc652r4m83d86ykplrnm9dg5gwdvzzn8ameanlvut35wy3gpz3mhxue69uhhztnnwashymtnw3ezucm0d5qzqru8mkz2q4gzsxg99q7pdneyx7n8p5u0afe3ntapj4sryxxmg4gpcdvgce', + [], + ); + assertEquals(html, ''); +}); + Deno.test('getMediaLinks', () => { const links = [ { href: 'https://example.com/image.png' }, diff --git a/src/utils/note.ts b/src/utils/note.ts index 00be4b1a..bae371ff 100644 --- a/src/utils/note.ts +++ b/src/utils/note.ts @@ -1,10 +1,11 @@ import 'linkify-plugin-hashtag'; import linkifyStr from 'linkify-string'; import linkify from 'linkifyjs'; -import { nip19, nip21, nip27 } from 'nostr-tools'; +import { nip19, nip27 } from 'nostr-tools'; import { Conf } from '@/config.ts'; import { MastodonMention } from '@/entities/MastodonMention.ts'; +import { html } from '@/utils/html.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; linkify.registerCustomProtocol('nostr', true); @@ -24,40 +25,53 @@ function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedN const links = linkify.find(content).filter(isLinkURL); const firstUrl = links.find(isNonMediaLink)?.href; - const html = linkifyStr(content, { + const result = linkifyStr(content, { render: { hashtag: ({ content }) => { const tag = content.replace(/^#/, ''); const href = Conf.local(`/tags/${tag}`); - return `#${tag}`; + return html``; }, url: ({ attributes, content }) => { try { - const { decoded } = nip21.parse(content); - const pubkey = getDecodedPubkey(decoded); - if (pubkey) { - const mention = mentions.find((m) => m.id === pubkey); - const npub = nip19.npubEncode(pubkey); - const acct = mention?.acct ?? npub; - const name = mention?.acct ?? npub.substring(0, 8); - const href = mention?.url ?? Conf.local(`/@${acct}`); - return `@${name}`; - } else { - return ''; + const { protocol, pathname } = new URL(content); + + if (protocol === 'nostr:') { + const match = pathname.match(new RegExp(`^${nip19.BECH32_REGEX.source}`)); + if (match) { + const bech32 = match[0]; + const extra = pathname.slice(bech32.length); + const decoded = nip19.decode(bech32); + const pubkey = getDecodedPubkey(decoded); + if (pubkey) { + const mention = mentions.find((m) => m.id === pubkey); + const npub = nip19.npubEncode(pubkey); + const acct = mention?.acct ?? npub; + const name = mention?.acct ?? npub.substring(0, 8); + const href = mention?.url ?? Conf.local(`/@${acct}`); + return html`@${name}${extra}`; + } else { + return ''; + } + } else { + return content; + } } } catch { - const attr = Object.entries(attributes) - .map(([name, value]) => `${name}="${value}"`) - .join(' '); - - return `${content}`; + // fallthrough } + + const attr = Object.entries(attributes) + .map(([name, value]) => `${name}="${value}"`) + .join(' '); + + return `${content}`; }, }, }).replace(/\n+$/, ''); return { - html, + html: result, links, firstUrl, };