├── public ├── logo.png ├── .DS_Store ├── manifest.json └── styles.css ├── screenshot.png ├── package.json ├── views ├── error.ejs ├── home.ejs └── mastodon.ejs ├── README.md ├── LICENSE.md ├── .gitignore └── app.js /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microformats/mf2.link/main/public/logo.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microformats/mf2.link/main/screenshot.png -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microformats/mf2.link/main/public/.DS_Store -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mf2.link", 3 | "version": "1.0.0", 4 | "description": "Convert page contents to mf2 and serve a minimal HTML representation of the underlying content", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "capjamesg", 10 | "license": "MIT 0", 11 | "dependencies": { 12 | "ejs": "^3.1.10", 13 | "express": "^4.21.2", 14 | "node-fetch": "^3.3.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /views/error.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Error 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

There was an error loading this page.

17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "mf2.link", 3 | "name": "mf2.link", 4 | "icons": [ 5 | { 6 | "src": "/logo.png", 7 | "type": "image/png", 8 | "sizes": "512x512 120x120 152x152 167x167 180x180 192x192 384x384", 9 | "purpose": "any maskable" 10 | } 11 | ], 12 | "id": "/?source=pwa", 13 | "start_url": "/?source=pwa", 14 | "background_color": "#fff", 15 | "display": "standalone", 16 | "scope": "/", 17 | "theme_color": "#AEE219", 18 | "description": "Create shareable links for Mastodon posts." 19 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mf2.link 2 | 3 | Generate embeddable links with mf2 markup for use within inline text and when sharing. 4 | 5 | ## Screenshot 6 | 7 | ![The mf2.link home page](screenshot.png) 8 | 9 | ## Installation 10 | 11 | To run the application, first install the required dependencies: 12 | 13 | ``` 14 | npm install 15 | ``` 16 | 17 | Then, open `config.js` and add your Bluesky handle and password. 18 | 19 | Finally, run the following command to start the application server: 20 | 21 | ``` 22 | npm start 23 | ``` 24 | 25 | ## License 26 | 27 | This project is licensed under an [MIT license](LICENSE). 28 | 29 | ## Contributors 30 | 31 | - capjamesg -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2023 capjamesg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: Arial, Helvetica, sans-serif; 3 | box-sizing: border-box; 4 | } 5 | html { 6 | padding: 10px; 7 | border-top: 5px solid #6CA300; 8 | } 9 | .author { 10 | display: flex; 11 | align-items: center; 12 | } 13 | .author-name h2, .author-name p { 14 | margin: 0; 15 | } 16 | .author-name { 17 | margin-left: 10px; 18 | } 19 | blockquote { 20 | margin-left: 0; 21 | padding: 0; 22 | border-left: 5px solid #ccc; 23 | padding-left: 10px; 24 | word-wrap: break-word; 25 | } 26 | main { 27 | max-width: 500px; 28 | } 29 | iframe { 30 | margin-top: 10px; 31 | border: 1px solid #ccc; 32 | } 33 | a { 34 | color: #6CA300; 35 | text-decoration: none; 36 | } 37 | .inner-embed { 38 | margin-top: 25px; 39 | } 40 | .embedded-image { 41 | height: 100%; 42 | width: 100%; 43 | border-radius: 10px; 44 | object-fit: cover; 45 | margin-top: 25px; 46 | } 47 | 48 | input { 49 | padding: 0.5em; 50 | border: 1px solid #ccc; 51 | border-radius: 0.25em; 52 | font-size: 1em; 53 | width: 100%; 54 | } 55 | button { 56 | background: #6CA300; 57 | color: #fff; 58 | border: none; 59 | cursor: pointer; 60 | padding: 10px; 61 | border-radius: 10px; 62 | margin-top: 10px; 63 | font-size: 1em; 64 | } 65 | .datelink { 66 | color: grey; 67 | text-decoration: none; 68 | font-size: 0.8em; 69 | margin-bottom: 10px; 70 | display: block; 71 | } 72 | select { 73 | padding: 0.5em; 74 | border: 1px solid #ccc; 75 | border-radius: 0.25em; 76 | font-size: 1em; 77 | width: 100%; 78 | } 79 | label { 80 | display: block; 81 | margin-top: 10px; 82 | margin-bottom: 10px; 83 | font-weight: bold; 84 | } 85 | .external-embed { 86 | margin-top: 25px; 87 | border: 1px solid #ccc; 88 | border-radius: 10px; 89 | } 90 | .external-embed img { 91 | margin-top: 0; 92 | border-bottom-left-radius: 0%; 93 | border-bottom-right-radius: 0%; 94 | } 95 | .external-embed .text-card { 96 | padding: 10px; 97 | } 98 | .external-embed .text-card .title { 99 | font-size: 1.2em; 100 | margin-top: 0; 101 | } 102 | .text-card .description { 103 | color: black; 104 | } 105 | .poll { 106 | margin-top: 25px; 107 | border: 1px solid #ccc; 108 | border-radius: 10px; 109 | padding: 10px; 110 | } 111 | .poll-answers { 112 | list-style: none; 113 | padding: 0; 114 | } 115 | .poll-answer { 116 | padding-bottom: 10px; 117 | border-bottom: 1px solid #ccc; 118 | padding-top: 10px; 119 | } 120 | .poll-answer p { 121 | margin: 0; 122 | } 123 | .poll-answer:first-child { 124 | padding-top: 0; 125 | } 126 | .poll-answer:last-child { 127 | padding-bottom: 0; 128 | } 129 | progress { 130 | width: 100%; 131 | height: 10px; 132 | border: none; 133 | border-radius: 10px; 134 | margin-top: 10px; 135 | } 136 | @media screen and (min-width: 500px) { 137 | input { 138 | width: 100%; 139 | } 140 | } -------------------------------------------------------------------------------- /views/home.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | mf2.link 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |

Generate embeddable links for Mastodon posts 🔗

28 | 29 |

Generate embeddable links with mf2 markup for use within inline text and when sharing.

30 | 31 |

The following services are supported:

32 | 33 | 36 | 37 |

To generate links for Bluesky posts, check out bsky.link.

38 | 39 |
40 | 41 | 42 | 43 | 46 | 47 |
48 | 49 |
50 | 51 |
52 | 53 | 56 |
57 | 103 | 104 | -------------------------------------------------------------------------------- /views/mastodon.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Post by <%= data.account.display_name %> 9 | 10 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | <% if (reply) { %> 30 |

In reply to a post from <%= reply.account.display_name %>

31 | <% } %> 32 |
33 | <%= data.account.display_name %>'s profile picture 34 |
35 |

<%= data.account.display_name %>

36 |

<%= data.account.username %>

37 |
38 |
39 |
40 | <%- data.content %> 41 | <% if (data.poll) { %> 42 |
43 |
    44 | <% data.poll.options.forEach(function(option) { %> 45 |
  • 46 |

    <%= option.title %> (<%= option.votes_count %> votes)

    47 | 48 |
  • 49 | <% }) %> 50 |
51 |
52 | <% } %> 53 | <% if (data.media_attachments.length > 0) { %> 54 |
55 | <% data.media_attachments.forEach(function(media) { %> 56 | <% if (media.type == 'image') { %> 57 | <%= media.description %> 58 | <% } else if (media.type == 'video') { %> 59 | 60 | <% } else if (media.type == 'gifv') { %> 61 | 62 | <% } else if (media.type == 'audio') { %> 63 | 64 | <% } else if (media.type == 'unknown') { %> 65 | <%= media.description %> 66 | <% } %> 67 | <% }) %> 68 |
69 | <% } %> 70 | <% if (data.card) { %> 71 | 72 |
73 |
74 |

<%= data.card.title %>

75 |

<%= data.card.description %>

76 |
77 |
78 |
79 | <% } %> 80 |
81 | 82 |
83 | ❤️ <%= data.favourites_count %> likes 84 | 🔄 <%= data.reblogs_count %> reposts 85 | 💬 <%= data.replies_count %> replies 86 |
87 |
88 | 89 | 90 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const ejs = require("ejs"); 3 | 4 | const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); 5 | 6 | const PORT = process.env.PORT || 3010; 7 | var Microformats = require("microformat-node"), Cheerio = require('cheerio'), options = {}; 8 | 9 | const app = express(); 10 | 11 | app.set("view engine", "ejs"); 12 | app.use(express.static("public")); 13 | // allow json 14 | app.use(express.json()); 15 | 16 | const SERVICES = { 17 | "mastodon": "mastodon" 18 | } 19 | 20 | 21 | function compileVCard (last_name, first_name, additional_name, honorific_prefix, honorific_suffix, full_name, organization, title, tel, email, url) { 22 | var final_vcard = ` 23 | BEGIN:VCARD 24 | VERSION:3.0 25 | `; 26 | 27 | if (first_name || last_name) { 28 | first_name = first_name || ""; 29 | last_name = last_name || ""; 30 | additional_name = additional_name || ""; 31 | honorific_prefix = honorific_prefix || ""; 32 | honorific_suffix = honorific_suffix || ""; 33 | 34 | final_vcard += ` 35 | N:${last_name};${first_name};${additional_name};${honorific_prefix};${honorific_suffix} 36 | `; 37 | } 38 | 39 | final_vcard += ` 40 | FN:${full_name} 41 | `; 42 | 43 | final_vcard += ` 44 | ORG:${organization} 45 | `; 46 | 47 | final_vcard += ` 48 | TITLE:${title} 49 | `; 50 | 51 | if (tel) { 52 | final_vcard += ` 53 | TEL;TYPE=home,voice;VALUE=uri:tel:${tel} 54 | `; 55 | } 56 | 57 | // add email 58 | final_vcard += ` 59 | EMAIL:${email} 60 | `; 61 | 62 | // add url 63 | final_vcard += ` 64 | URL:${url} 65 | `; 66 | 67 | // end vcard 68 | final_vcard += ` 69 | END:VCARD 70 | `; 71 | 72 | // strip whitespace 73 | final_vcard = final_vcard.replace(/^\s+|\s+$/g, ''); 74 | 75 | return final_vcard; 76 | } 77 | 78 | function get(object, key, default_value) { 79 | var result = object[key]; 80 | return (typeof result !== "undefined") ? result : default_value; 81 | } 82 | 83 | app.route("/").get(async (req, res) => { 84 | res.render("home"); 85 | }); 86 | 87 | app.route("/").post(async (req, res) => { 88 | // get fromjson body 89 | var url = req.body.url; 90 | var service = req.body.service; 91 | 92 | if (!url) { 93 | // load home.ejs 94 | res.render("home"); 95 | return; 96 | } 97 | 98 | if (!service || !SERVICES[service]) { 99 | res.render("home", { 100 | error: "Invalid service" 101 | }); 102 | return; 103 | } 104 | 105 | res.redirect("/" + SERVICES[service] + "?url=" + url); 106 | return; 107 | }); 108 | 109 | 110 | app.route("/mastodon").get(async (req, res) => { 111 | var url = req.query.url; 112 | 113 | if (!url) { 114 | // load home.ejs 115 | res.render("home"); 116 | return; 117 | } 118 | 119 | try { 120 | var parsed_url = new URL(url); 121 | } catch (e) { 122 | res.render("error", { 123 | error: "Invalid URL" 124 | }); 125 | return; 126 | } 127 | 128 | var domain = parsed_url.hostname; 129 | 130 | var status_id = parsed_url.pathname.split("/"); 131 | 132 | status_id = status_id[status_id.length - 1]; 133 | 134 | var query_url = "https://" + domain + "/api/v1/statuses/" + status_id; 135 | 136 | fetch(query_url, { 137 | method: "GET", 138 | headers: { 139 | "Content-Type": "application/json" 140 | } 141 | }).then((response) => { 142 | // if not json, error 143 | if (!response.ok) { 144 | res.render("error", { 145 | error: "There was an error retrieving this post." 146 | }); 147 | return; 148 | } 149 | response.json().then((data) => { 150 | // if not json, error 151 | if (!data) { 152 | res.render("error", { 153 | error: "There was an error retrieving this post." 154 | }); 155 | return; 156 | } 157 | var is_reply = data.in_reply_to_id != null; 158 | 159 | var date = new Date(data.created_at); 160 | 161 | data.created_at = date.toLocaleString("en-US", { 162 | month: "long", 163 | day: "numeric", 164 | year: "numeric", 165 | hour: "numeric", 166 | minute: "numeric", 167 | second: "numeric", 168 | hour12: true, 169 | }).replace(",", ""); 170 | 171 | if (is_reply) { 172 | var reply_url = "https://" + domain + "/api/v1/statuses/" + data.in_reply_to_id; 173 | 174 | fetch(reply_url, { 175 | method: "GET", 176 | headers: { 177 | "Content-Type": "application/json" 178 | } 179 | }).then((response) => { 180 | response.json().then((reply_data) => { 181 | res.render("mastodon", { 182 | data: data, 183 | reply: reply_data 184 | }); 185 | }); 186 | }).catch((err) => { 187 | res.render("error", { 188 | error: "There was an error retrieving this post." 189 | }); 190 | return; 191 | }); 192 | } else { 193 | res.render("mastodon", { 194 | data: data, 195 | reply: null 196 | }); 197 | } 198 | }).catch((err) => { 199 | res.render("error", { 200 | error: "There was an error retrieving this post." 201 | }); 202 | return; 203 | }); 204 | }).catch((err) => { 205 | res.render("error", { 206 | error: "There was an error retrieving this post." 207 | }); 208 | return; 209 | }); 210 | }); 211 | 212 | app.route("/hcard").get(async (req, res) => { 213 | var url = req.query.url; 214 | 215 | if (!url) { 216 | res.render("error", { 217 | error: "Invalid URL" 218 | }); 219 | return; 220 | } 221 | 222 | try { 223 | var parsed_url = new URL(url); 224 | } catch (e) { 225 | res.render("error", { 226 | error: "Invalid URL" 227 | }); 228 | return; 229 | } 230 | 231 | fetch(url, { 232 | method: "GET", 233 | headers: { 234 | "Content-Type": "text/html" 235 | } 236 | }).then((response) => { 237 | response.text().then((data) => { 238 | console.log(data); 239 | options.html = data; 240 | 241 | Microformats.get(options, function (err, data) { 242 | // con 243 | console.log(data, err) 244 | if (err) { 245 | res.render("error", { 246 | error: "There was an error parsing this page." 247 | }); 248 | return; 249 | } 250 | 251 | var hcard = null; 252 | 253 | // find hcard 254 | for (var i = 0; i < data.items.length; i++) { 255 | // console.log(data.items[i]); 256 | if (data.items[i].type.includes("h-card")) { 257 | hcard = data.items[i].properties; 258 | break; 259 | } 260 | } 261 | 262 | if (!hcard) { 263 | // console.log(hcard); 264 | res.render("error", { 265 | error: "No h-card was found." 266 | }); 267 | return; 268 | } 269 | 270 | console.log(hcard) 271 | 272 | // if org, expand out 273 | if (hcard.org) { 274 | var org_titles = []; 275 | var orgs = []; 276 | 277 | for (var i = 0; i < hcard.org.length; i++) { 278 | var org = hcard.org[i].value; 279 | var title = hcard.org[i].properties.name; 280 | 281 | orgs.push(org); 282 | org_titles.push(title); 283 | } 284 | 285 | // join orgs as string 286 | hcard.title = org_titles.join(",").replace(/,/g, ", "); 287 | hcard.org = orgs.join(",").replace(/,/g, ", "); 288 | } 289 | 290 | var vcard = compileVCard( 291 | get(hcard, "family-name", [null])[0], 292 | get(hcard, "given-name", [null])[0], 293 | get(hcard, "additional-name", [null])[0], 294 | get(hcard, "honorific-prefix", [null])[0], 295 | get(hcard, "honorific-suffix", [null])[0], 296 | get(hcard, "name", [null])[0], 297 | get(hcard, "org", [null]), 298 | get(hcard, "title", [null]), 299 | get(hcard, "tel", [null])[0], 300 | get(hcard, "email", [null])[0], 301 | url 302 | ) 303 | 304 | res.set("Content-Type", "text/plain"); 305 | res.send(vcard); 306 | }); 307 | }); 308 | }).catch((err) => { 309 | res.render("error", { 310 | error: "There was an error retrieving this page." 311 | }); 312 | return; 313 | }); 314 | }); 315 | 316 | app.listen(PORT, () => { 317 | console.log(`Server started on port ${PORT}`); 318 | }); 319 | --------------------------------------------------------------------------------