From e243350f95fb4d12f22257ca23416fc6f76d15f3 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Fri, 13 Jun 2025 17:00:15 -0400 Subject: [PATCH] Big bang --- .gitignore | 3 + package.json | 36 +++++ pnpm-lock.yaml | 328 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 + src/parser.ts | 157 ++++++++++++++++++ src/renderers/html.ts | 54 +++++++ src/renderers/index.ts | 2 + src/renderers/markmark.ts | 54 +++++++ src/types.ts | 31 ++++ tsconfig.json | 16 ++ 10 files changed, 684 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/index.ts create mode 100644 src/parser.ts create mode 100644 src/renderers/html.ts create mode 100644 src/renderers/index.ts create mode 100644 src/renderers/markmark.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..035900b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +lib +node_modules +.idea diff --git a/package.json b/package.json new file mode 100644 index 0000000..bde08bc --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "mark-mark", + "version": "0.1.0", + "description": "A TypeScript MarkMark parser/renderer collection", + "homepage": "https://garrettmills.dev/markmark", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "directories": { + "lib": "lib" + }, + "files": [ + "lib/**/*" + ], + "repository": { + "type": "git", + "url": "https://code.garrettmills.dev/garrettmills/mark-mark" + }, + "scripts": { + "build": "rimraf lib && tsc" + }, + "keywords": [ + "markmark", + "bookmarks" + ], + "author": "Garrett Mills ", + "license": "AGPL-3.0-only", + "packageManager": "pnpm@10.6.5", + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.8.3" + }, + "dependencies": { + "hasha": "^6.0.0", + "marked": "^15.0.12" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a3bb31f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,328 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + hasha: + specifier: ^6.0.0 + version: 6.0.0 + marked: + specifier: ^15.0.12 + version: 15.0.12 + devDependencies: + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + +packages: + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + glob@11.0.2: + resolution: {integrity: sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==} + engines: {node: 20 || >=22} + hasBin: true + + hasha@6.0.0: + resolution: {integrity: sha512-MLydoyGp9QJcjlhE5lsLHXYpWayjjWqkavzju2ZWD2tYa1CgmML1K1gWAu22BLFa2eZ0OfvJ/DlfoVjaD54U2Q==} + engines: {node: '>=18'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + +snapshots: + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + glob@11.0.2: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + + hasha@6.0.0: + dependencies: + is-stream: 3.0.0 + type-fest: 4.41.0 + + is-fullwidth-code-point@3.0.0: {} + + is-stream@3.0.0: {} + + isexe@2.0.0: {} + + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + + lru-cache@11.1.0: {} + + marked@15.0.12: {} + + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + + minipass@7.1.2: {} + + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.0: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + + rimraf@6.0.1: + dependencies: + glob: 11.0.2 + package-json-from-dist: 1.0.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + type-fest@4.41.0: {} + + typescript@5.8.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4fca99e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './parser' +export * from './renderers' \ No newline at end of file diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..7966691 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,157 @@ +import * as marked from 'marked' +import {FrontMatter, isNamedSection, Link, MarkMark, Section} from './types' +import * as hasha from 'hasha' + +export class Parser { + public parse(content: string): MarkMark { + const mm: MarkMark = { + frontmatter: { + syntax: 'v1', + }, + sections: [], + } + + let foundFrontmatter: boolean = false + let currentSection: Section = { links: [] } + let currentLink: Link|undefined + let sectionListItemsRemaining: number = 0 + let linkListItemsRemaining: number = 0 + const walkTokens = (token: marked.Token) => { + // Parse out the front-matter + if ( token.type === 'paragraph' && !foundFrontmatter && token.raw.trim().startsWith('[//]:') ) { + mm.frontmatter = this.parseFrontmatter(token.raw.trim()) + foundFrontmatter = true + return + } + + + // When we encounter a heading, start a new section + if ( token.type === 'heading' ) { + if ( currentSection.links.length ) mm.sections.push(currentSection) + currentSection = { + title: token.text, + links: [] + } + return + } + + // When we encounter a non-frontmatter paragraph and we're in a section, + // assume it's the description for the section + if ( token.type === 'paragraph' && isNamedSection(currentSection) && !token.raw.trim().startsWith('[//]:') ) { + currentSection.description = token.raw + return + } + + + // If we're not currently parsing a section and we encounter a list, + // start parsing that list (grab the # of items in the list) + if ( !sectionListItemsRemaining && token.type === 'list' ) { + token.items.map((listItem: marked.Tokens.ListItem) => { + listItem.tokens.map(token => { + // Explicitly mark the top-level text/list tokens as "section" items + // to prevent double-counting. This is because `marked` parses text + //
  • 's as a text-w/in-a-text. + (token as any).mmIsSectionLevel = true + }) + }) + + sectionListItemsRemaining = token.items.length + 1 + return // to avoid conflict with linkListItemsRemaining + } + + // If we're parsing a section list and we're NOT parsing a link's URL list + // and we encounter some text, assume it's the name of a link and start parsing it + if ( sectionListItemsRemaining && !linkListItemsRemaining && token.type === 'text' && (token as any).mmIsSectionLevel ) { + const [title, date] = this.parseTitleAndDate(token.text.split(' #')[0].trim()) + currentLink = { + title, + date, + hash: hasha.hashSync(token.text, { algorithm: 'sha256' }), + tags: this.parseTags(token.text), + urls: [], + } + + sectionListItemsRemaining -= 1 + return + } + + + // If we're parsing a section list but not a link URL list and we encounter a list, + // assume it's the inner list of link URLs and start parsing it + if ( sectionListItemsRemaining && !linkListItemsRemaining && token.type === 'list' ) { + linkListItemsRemaining = token.items.length + 1 + } + + // If we're parsing the URL list for a link and we encounter a link, + // add its URL to the URLs for currentLink + if ( currentLink && sectionListItemsRemaining && linkListItemsRemaining && token.type === 'link' ) { + currentLink.urls.push(token.href) + linkListItemsRemaining -= 1 + } + + // If we were parsing a link and we ran out of URLs for the link, + // stop parsing that link and push it into the section + if ( currentLink && linkListItemsRemaining === 1 ) { + linkListItemsRemaining = 0 + currentSection.links.push(currentLink) + currentLink = undefined + + // If that was the last link in the section, end the section + if ( sectionListItemsRemaining === 1 ) { + mm.sections.push(currentSection) + sectionListItemsRemaining = 0 + currentSection = { links: [] } + } + } + } + + marked.marked.use({ walkTokens }) + marked.marked.parse(content) + + mm.sections.push(currentSection) + mm.sections = mm.sections.filter(s => s.links.length) + + return mm + } + + protected parseFrontmatter(text: string): FrontMatter { + const fm: FrontMatter = { + syntax: 'v1', + } + + const matcher = /\[\/\/]:\s+#\(([a-zA-Z0-9_\-]+):\s+(.*)\)/g + const rawFrontmatter: Record = + [...text.matchAll(matcher)] + .map(match => ({[match[1]]: match[2]})) + .reduce((carry, current) => ({...carry, ...current}), {}) + + if ( rawFrontmatter['markmark-author-name'] ) fm.authorName = rawFrontmatter['markmark-author-name'] + if ( rawFrontmatter['markmark-author-email'] ) fm.authorEmail = rawFrontmatter['markmark-author-email'] + if ( rawFrontmatter['markmark-author-href'] ) fm.authorHref = rawFrontmatter['markmark-author-href'] + + return fm + } + + protected parseTitleAndDate(text: string): [string, Date|undefined] { + text = text.trim() + + const dateMatcher = /(.*)\(([0-9\-+:TZ]+)\)$/g + const result = dateMatcher.exec(text) + if ( !result ) { + return [text, undefined] + } + + const [, title, dateString] = result + const date = new Date(dateString) + if ( isNaN(date.getTime()) ) { + return [text, undefined] + } + + return [title.trim(), date] + } + + protected parseTags(text: string): string[] { + const matcher = /#([a-zA-Z0-9_\-]+)/g + return [...text.matchAll(matcher)].map(x => x[1]) + } +} diff --git a/src/renderers/html.ts b/src/renderers/html.ts new file mode 100644 index 0000000..3b035b8 --- /dev/null +++ b/src/renderers/html.ts @@ -0,0 +1,54 @@ +import * as marked from 'marked' +import {isNamedSection, MarkMark} from '../types' + +export class HtmlRenderer { + public render(mm: MarkMark): string { + let mmLines: string[] = [] + + for ( const section of mm.sections ) { + mmLines.push('
    ') + + // if this section has a title/description, write those out + if ( isNamedSection(section) ) { + mmLines.push(`

    ${marked.marked.parse(section.title)}

    `) + if ( section.description ) { + mmLines.push(`

    ${marked.marked.parse(section.description)}

    `) + } + } + + mmLines.push('
      ') + + for ( const link of section.links ) { + let linkTitle = `${link.title}` + + if ( link.date ) { + linkTitle += ` (${this.formatDate(link.date)})` + } + + if ( link.tags.length ) { + linkTitle += ` ${link.tags.map(x => '#' + x + '').join(' ')}` + } + + mmLines.push(`') + } + + mmLines.push('
    ') + mmLines.push('
    ') + } + + return mmLines.join('\n') + } + + private formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } +} diff --git a/src/renderers/index.ts b/src/renderers/index.ts new file mode 100644 index 0000000..f7769b8 --- /dev/null +++ b/src/renderers/index.ts @@ -0,0 +1,2 @@ +export * from './html' +export * from './markmark' diff --git a/src/renderers/markmark.ts b/src/renderers/markmark.ts new file mode 100644 index 0000000..0f8156f --- /dev/null +++ b/src/renderers/markmark.ts @@ -0,0 +1,54 @@ +import {isNamedSection, MarkMark} from '../types' + +export class MarkMarkRenderer { + public render(mm: MarkMark, frontmatter: boolean = true): string { + let mmLines: string[] = ['\n'] + + // Write the frontmatter + if ( frontmatter ) { + mmLines.push(`[//]: #(markmark-syntax: ${mm.frontmatter.syntax})`) + if (mm.frontmatter.authorName) mmLines.push(`[//]: #(markmark-author-name: ${mm.frontmatter.authorName})`) + if (mm.frontmatter.authorEmail) mmLines.push(`[//]: #(markmark-author-email: ${mm.frontmatter.authorEmail})`) + if (mm.frontmatter.authorHref) mmLines.push(`[//]: #(markmark-author-href: ${mm.frontmatter.authorHref})`) + } + + for ( const section of mm.sections ) { + mmLines.push('\n') + + // if this section has a title/description, write those out + if ( isNamedSection(section) ) { + mmLines.push(`# ${section.title}\n`) + if ( section.description ) { + mmLines.push(`${section.description}\n`) + } + } + + for ( const link of section.links ) { + let linkTitle = `- ${link.title}` + + if ( link.date ) { + linkTitle += ` (${this.formatDate(link.date)})` + } + + if ( link.tags.length ) { + linkTitle += ` ${link.tags.map(x => '#' + x).join(' ')}` + } + + mmLines.push(linkTitle) + + for ( const url of link.urls ) { + mmLines.push(` - ${url}`) + } + } + } + + return mmLines.join('\n') + } + + private formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7675b7f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,31 @@ +/** A typescript-compatible version of Object.hasOwnProperty. Yoinked from @extollo/lib. */ +export function hasOwnProperty(obj: X, prop: Y): obj is X & Record { + return Object.hasOwnProperty.call(obj, prop) +} + +export type MarkMark = { + frontmatter: FrontMatter, + sections: Section[], +} + +export type FrontMatter = { + syntax: 'v1', + authorName?: string, + authorEmail?: string, + authorHref?: string, +} + +export type AnonymousSection = { links: Link[] } +export type NamedSection = { title: string, description?: string, links: Link[] } +export type Section = AnonymousSection | NamedSection + +export const isNamedSection = (what: Section): what is NamedSection => + hasOwnProperty(what, 'title') && (typeof what.title === 'string') + +export type Link = { + hash: string, + title: string, + date?: Date, + tags: string[], + urls: string[], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1286eb8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "declaration": true, + "outDir": "./lib", + "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "lib": ["ESNext"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} +