├── .github ├── pull_request_template.md └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── README.md ├── mapping.js ├── package-lock.json ├── package.json ├── test └── transformTest.js └── transform.js /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | If your Pull Request is about a change in the `mapping.js` file, please start your commit message with `mapping: `, it helps speed up the npm package release process. 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "lts/*" 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Release 22 | env: 23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | run: npx semantic-release 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "quoteProps": "preserve" 4 | } 5 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | "releaseRules": [{ "type": "mapping", "release": "patch" }] 8 | } 9 | ], 10 | "@semantic-release/release-notes-generator", 11 | "@semantic-release/changelog", 12 | "@semantic-release/npm", 13 | "@semantic-release/github", 14 | "@semantic-release/git" 15 | ], 16 | "preset": "angular" 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.48](https://github.com/tweetback/tweetback-canonical/compare/v2.0.47...v2.0.48) (2025-04-28) 2 | 3 | ## [2.0.47](https://github.com/tweetback/tweetback-canonical/compare/v2.0.46...v2.0.47) (2025-04-01) 4 | 5 | ## [2.0.46](https://github.com/tweetback/tweetback-canonical/compare/v2.0.45...v2.0.46) (2024-11-22) 6 | 7 | ## [2.0.45](https://github.com/tweetback/tweetback-canonical/compare/v2.0.44...v2.0.45) (2024-11-13) 8 | 9 | ## [2.0.44](https://github.com/tweetback/tweetback-canonical/compare/v2.0.43...v2.0.44) (2024-09-19) 10 | 11 | ## [2.0.43](https://github.com/tweetback/tweetback-canonical/compare/v2.0.42...v2.0.43) (2024-09-19) 12 | 13 | ## [2.0.42](https://github.com/tweetback/tweetback-canonical/compare/v2.0.41...v2.0.42) (2024-09-19) 14 | 15 | ## [2.0.41](https://github.com/tweetback/tweetback-canonical/compare/v2.0.40...v2.0.41) (2024-06-13) 16 | 17 | ## [2.0.40](https://github.com/tweetback/tweetback-canonical/compare/v2.0.39...v2.0.40) (2024-02-09) 18 | 19 | ## [2.0.39](https://github.com/tweetback/tweetback-canonical/compare/v2.0.38...v2.0.39) (2024-02-08) 20 | 21 | ## [2.0.38](https://github.com/tweetback/tweetback-canonical/compare/v2.0.37...v2.0.38) (2023-12-13) 22 | 23 | ## [2.0.37](https://github.com/tweetback/tweetback-canonical/compare/v2.0.36...v2.0.37) (2023-09-03) 24 | 25 | ## [2.0.36](https://github.com/tweetback/tweetback-canonical/compare/v2.0.35...v2.0.36) (2023-08-23) 26 | 27 | ## [2.0.35](https://github.com/tweetback/tweetback-canonical/compare/v2.0.34...v2.0.35) (2023-08-12) 28 | 29 | ## [2.0.34](https://github.com/tweetback/tweetback-canonical/compare/v2.0.33...v2.0.34) (2023-08-12) 30 | 31 | ## [2.0.33](https://github.com/tweetback/tweetback-canonical/compare/v2.0.32...v2.0.33) (2023-05-22) 32 | 33 | ## [2.0.32](https://github.com/tweetback/tweetback-canonical/compare/v2.0.31...v2.0.32) (2023-03-26) 34 | 35 | ## [2.0.31](https://github.com/tweetback/tweetback-canonical/compare/v2.0.30...v2.0.31) (2023-03-26) 36 | 37 | ## [2.0.30](https://github.com/tweetback/tweetback-canonical/compare/v2.0.29...v2.0.30) (2023-03-26) 38 | 39 | ## [2.0.29](https://github.com/tweetback/tweetback-canonical/compare/v2.0.28...v2.0.29) (2023-01-30) 40 | 41 | ## [2.0.28](https://github.com/tweetback/tweetback-canonical/compare/v2.0.27...v2.0.28) (2023-01-08) 42 | 43 | ## [2.0.27](https://github.com/tweetback/tweetback-canonical/compare/v2.0.26...v2.0.27) (2023-01-04) 44 | 45 | ## [2.0.26](https://github.com/tweetback/tweetback-canonical/compare/v2.0.25...v2.0.26) (2023-01-03) 46 | 47 | ## [2.0.25](https://github.com/tweetback/tweetback-canonical/compare/v2.0.24...v2.0.25) (2023-01-02) 48 | 49 | ## [2.0.24](https://github.com/tweetback/tweetback-canonical/compare/v2.0.23...v2.0.24) (2022-12-30) 50 | 51 | ## [2.0.23](https://github.com/tweetback/tweetback-canonical/compare/v2.0.22...v2.0.23) (2022-12-29) 52 | 53 | ## [2.0.22](https://github.com/tweetback/tweetback-canonical/compare/v2.0.21...v2.0.22) (2022-12-29) 54 | 55 | ## [2.0.21](https://github.com/tweetback/tweetback-canonical/compare/v2.0.20...v2.0.21) (2022-12-29) 56 | 57 | ## [2.0.20](https://github.com/tweetback/tweetback-canonical/compare/v2.0.19...v2.0.20) (2022-12-21) 58 | 59 | ## [2.0.19](https://github.com/tweetback/tweetback-canonical/compare/v2.0.18...v2.0.19) (2022-12-20) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * Use HTTPS instead ([58df843](https://github.com/tweetback/tweetback-canonical/commit/58df8434f8da1d2da821bd1b3e5540a10aba2759)) 65 | 66 | ## [2.0.18](https://github.com/tweetback/tweetback-canonical/compare/v2.0.17...v2.0.18) (2022-12-20) 67 | 68 | ## [2.0.17](https://github.com/tweetback/tweetback-canonical/compare/v2.0.16...v2.0.17) (2022-12-19) 69 | 70 | ## [2.0.16](https://github.com/tweetback/tweetback-canonical/compare/v2.0.15...v2.0.16) (2022-12-19) 71 | 72 | ## [2.0.15](https://github.com/tweetback/tweetback-canonical/compare/v2.0.14...v2.0.15) (2022-12-16) 73 | 74 | ## [2.0.14](https://github.com/tweetback/tweetback-canonical/compare/v2.0.13...v2.0.14) (2022-12-16) 75 | 76 | ## [2.0.13](https://github.com/tweetback/tweetback-canonical/compare/v2.0.12...v2.0.13) (2022-12-16) 77 | 78 | ## [2.0.12](https://github.com/tweetback/tweetback-canonical/compare/v2.0.11...v2.0.12) (2022-12-12) 79 | 80 | ## [2.0.11](https://github.com/tweetback/tweetback-canonical/compare/v2.0.10...v2.0.11) (2022-12-11) 81 | 82 | ## [2.0.10](https://github.com/tweetback/tweetback-canonical/compare/v2.0.9...v2.0.10) (2022-12-09) 83 | 84 | ## [2.0.9](https://github.com/tweetback/tweetback-canonical/compare/v2.0.8...v2.0.9) (2022-12-08) 85 | 86 | ## [2.0.8](https://github.com/tweetback/tweetback-canonical/compare/v2.0.7...v2.0.8) (2022-12-03) 87 | 88 | ## [2.0.7](https://github.com/tweetback/tweetback-canonical/compare/v2.0.6...v2.0.7) (2022-12-02) 89 | 90 | ## [2.0.6](https://github.com/tweetback/tweetback-canonical/compare/v2.0.5...v2.0.6) (2022-12-01) 91 | 92 | ## [2.0.5](https://github.com/tweetback/tweetback-canonical/compare/v2.0.4...v2.0.5) (2022-12-01) 93 | 94 | ## [2.0.4](https://github.com/tweetback/tweetback-canonical/compare/v2.0.3...v2.0.4) (2022-12-01) 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @tweetback/canonical 2 | 3 | A package to resolve twitter URLs to new canonically hosted twitter backups. 4 | 5 | ## Installation 6 | 7 | The package is available on npm: https://www.npmjs.com/package/@tweetback/canonical 8 | 9 | ``` 10 | npm install @tweetback/canonical 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | import {transform} from "@tweetback/canonical"; 17 | 18 | transform("https://twitter.com/zachleat"); 19 | // Returns "https://www.zachleat.com/twitter/" 20 | 21 | transform("https://twitter.com/eleven_ty"); 22 | // Returns "https://twitter.11ty.dev/" 23 | ``` 24 | 25 | Works with status URLs: 26 | 27 | ```js 28 | transform("https://twitter.com/zachleat/status/123"); 29 | // Returns "https://www.zachleat.com/twitter/123" 30 | ``` 31 | 32 | Other features: 33 | 34 | * Passthrough any valid URLs as normal. 35 | * Preserves trailing slashes (trailing slashes are optional) 36 | * Normalizes duplicate slashes in the pathname 37 | 38 | ## Add your own Twitter Archive: 39 | 40 | You needn’t use tweetback to add your archive here. The only requirement here is that your archive has URL parity and has individually addressable URLs for each status. 41 | 42 | Just create a PR with your addition to the `mapping.js` file and we’ll have a look! 43 | 44 | Please start your commit message with `mapping: `, it helps speed up the npm package release process. 45 | 46 | ## The best example 47 | 48 | This status https://twitter.11ty.dev/1559312029340557315 links to @TerribleMia’s archive which links _back_ to the @eleven_ty archive. Threading across archives 🏆 while allowing each instance to maintain their own data. 49 | -------------------------------------------------------------------------------- /mapping.js: -------------------------------------------------------------------------------- 1 | // Twitter username (no @) => full canonical archive URL 2 | export const mapping = { 3 | "zachleat": "https://www.zachleat.com/twitter/", 4 | "eleven_ty": "https://twitter.11ty.dev", 5 | "matthewcp": "https://matthewphillips.info/tweets/", 6 | "nhoizey": "https://twitter.nicolas-hoizey.com", 7 | "rknightuk": "https://hellsite.rknight.me", 8 | "steren": "https://twitter.steren.fr", 9 | "saneef": "https://tweets.saneef.com/", 10 | "cutewitchphoebe": "https://twitter.catgirlin.space", 11 | "madelinecatgirl": "https://twitter.catgirlin.space", 12 | "purplevioletsky": "https://twitter.catgirlin.space", 13 | "type__error": "https://twitter.localghost.dev", 14 | "Chr1sHayes": "https://tweetback.hayes.software", 15 | "terribleMia": "https://tweets.miriamsuzanne.com", 16 | "iamchrisburnell": "https://twitter.chrisburnell.com", 17 | "overflowhidden": "https://tweets.kimjohannesen.dk", 18 | "lindqvistus": "https://twitter.gustavlindqvist.se", 19 | "sil": "https://kryogenix.org/twitter/", 20 | "axbom": "https://twitter.axbom.com", 21 | "dryan": "https://dryan.com/tweets/", 22 | "edwardandrews": "https://tweets.aldreth.com/", 23 | "lewisdaleuk": "https://twitter.lewisdale.dev", 24 | "ZekeAranyLucas": "https://zekearanylucas.github.io/tweets/", 25 | "MarcoZehe": "https://twitter.marcos-leben.de", 26 | "MarcoInEnglish": "https://twitter.marcozehe.de", 27 | "jefflembeck": "https://twitter.jefflembeck.com", 28 | "philhawksworth": "https://www.hawksworx.com/note/tw/", 29 | "404boyfriend": "https://tweets.henry.codes", 30 | "xdesro": "https://tweets.henry.codes", 31 | "colinaut": "https://twitter.colinaut.com", 32 | "ginader": "http://tweets.ginader.com/", 33 | "Noleli": "https://projects.noahliebman.net/twitter/", 34 | "gauntface": "https://twitterarchive.gaunt.dev/", 35 | "jakejarvis": "https://tweets.jarv.is/", 36 | "mvsde": "https://twitter.fynn.be/", 37 | "samthegeek": "https://twarchive.samthegeek.net/", 38 | "rene_mobile": "https://twitterarchive.mayrhofer.eu.org/", 39 | "sentience": "https://kevinyank.com/twitter/", 40 | "cjtype": "https://twitterarchive.cjtype.com/", 41 | "jed_fox1": "https://tweets.jedfox.com", 42 | "capsinthehouse": "https://capsinthehouse.twitter-archive.reinhart1010.id", 43 | "santumerino": "https://www.santumerino.com/twitterarchive/", 44 | "codefoodpixels": "https://tweets.lukeb.co.uk/", 45 | "piraces_": "https://tweets.piraces.dev/", 46 | "bandit": "https://tweets.jamesn.net/", 47 | "mknepprath": "https://twitter.mknepprath.com/", 48 | "AaronGustafson": "https://twitter.aaron-gustafson.com", 49 | "cooljeanius": "https://cooljeanius.github.io/my_tweetback_archive/", 50 | "linusgroh": "https://twitter.linus.dev", 51 | "MinaMarkham": "https://tweets.mina.codes", 52 | "markemer": "https://markemer.blue", 53 | "bmann": "https://twitter.bmannconsulting.com", 54 | "_dhar": "https://olivier.audard.net/twitter/", 55 | "Robbb_J": "https://tweets.r0b.io/", 56 | "lachlanjc": "https://tweets.lachlanjc.com", 57 | "batbeeps": "https://tweets.beeps.website/", 58 | "kennethson": "https://twitter.ksbarnt.com", 59 | "sebduggan": "https://tweetback.sebduggan.uk", 60 | "jcolag": "https://jcolag.github.io/twitter", 61 | "edm00se": "https://tweets.edm00se.codes/", 62 | "box464": "https://twitter.box464.com", 63 | "harlanthefox": "https://tweets.studioyip.com/", 64 | "lorvsso": "https://twitter.jacklorusso.com", 65 | "tylersticka": "https://twitter.tylersticka.com/", 66 | "kanekotic": "https://tweetback.kanekotic.com/", 67 | "_julianoe": "https://julianoe.eu.org/twitter", 68 | "dalelane": "https://dalelane.github.io/twitter-archive/dalelane/", 69 | "MLforKids": "https://dalelane.github.io/twitter-archive/mlforkids/", 70 | "t1mmyb": "https://twitter.timandkathy.co.uk", 71 | "hteumeuleu": "https://tweets.hteumeuleu.fr", 72 | "mikestreety": "https://tweets.mikestreety.co.uk", 73 | "michaelsbecker": "https://tweets.msb.fyi", 74 | "glomtwit": "https://tweets.joe.gl", 75 | "joeglombek": "https://tweets.joe.gl", 76 | "lukestein": "https://lukestein.github.io/twitter/", 77 | "gesa": "https://twitter.akiro.se", 78 | "Redfire75369": "https://tweet.redfire.dev/", 79 | "ckirknielsen": "https://tweets.chriskirknielsen.com/", 80 | "zzamboni": "https://zzamboni.org/twitter/", 81 | "tomayac": "https://tomayac.com/tweets/", 82 | "boogheta": "https://boogheta.github.io/tweets-archive/", 83 | "S0lll0s": "https://tweets.s-ol.nu/", 84 | }; 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tweetback/canonical", 3 | "version": "2.0.48", 4 | "description": "A package to resolve twitter URLs to new canonically hosted twitter backups.", 5 | "type": "module", 6 | "main": "transform.js", 7 | "scripts": { 8 | "test": "npx ava", 9 | "semantic-release": "semantic-release" 10 | }, 11 | "keywords": [], 12 | "contributors": [ 13 | "Zach Leatherman (https://zachleat.com/)" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/tweetback/tweetback-canonical.git" 18 | }, 19 | "homepage": "https://github.com/tweetback/tweetback-canonical#readme", 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@semantic-release/changelog": "^6.0.2", 26 | "@semantic-release/git": "^10.0.1", 27 | "ava": "^5.1.0", 28 | "semantic-release": "^19.0.5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/transformTest.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import {transform, normalizeUrlSlashes} from "../transform.js"; 3 | 4 | test("normalizeUrlSlashes", t => { 5 | t.is(normalizeUrlSlashes("test"), "test"); 6 | t.is(normalizeUrlSlashes("test", "test"), "test/test"); 7 | t.is(normalizeUrlSlashes("test/"), "test/"); 8 | t.is(normalizeUrlSlashes("/test/", "/test/"), "/test/test/"); 9 | }); 10 | 11 | test("Missing from mapping (passthrough)", t => { 12 | t.is(transform("https://example.com"), "https://example.com"); 13 | t.is(transform("https://example.com/"), "https://example.com/"); 14 | t.is(transform("@zachleat@zachleat.com"), "@zachleat@zachleat.com"); 15 | t.is(transform("/"), "/"); 16 | t.is(transform("ht/twit"), "ht/twit"); 17 | }); 18 | 19 | 20 | test("Plain Transform", t => { 21 | t.is(transform("https://twitter.com/zachleat"), "https://www.zachleat.com/twitter/"); 22 | t.is(transform("https://twitter.com/zachleat/"), "https://www.zachleat.com/twitter/"); 23 | 24 | t.is(transform("https://twitter.com/eleven_ty"), "https://twitter.11ty.dev/"); 25 | t.is(transform("https://twitter.com/eleven_ty/"), "https://twitter.11ty.dev/"); 26 | }); 27 | 28 | test("Transform with Status", t => { 29 | t.is(transform("https://twitter.com/zachleat/status/123"), "https://www.zachleat.com/twitter/123"); 30 | t.is(transform("https://twitter.com/eleven_ty/status/123"), "https://twitter.11ty.dev/123"); 31 | }); 32 | 33 | test("Preserve trailing slashes", t=> { 34 | t.is(transform("https://twitter.com/zachleat/status/123/"), "https://www.zachleat.com/twitter/123/"); 35 | t.is(transform("https://twitter.com/eleven_ty/status/123/"), "https://twitter.11ty.dev/123/"); 36 | }); 37 | 38 | test("Case sensitivity", t=> { 39 | t.is(transform("https://twitter.com/terribleMia/status/123/"), "https://tweets.miriamsuzanne.com/123/"); 40 | t.is(transform("https://twitter.com/terriblemia/status/123/"), "https://tweets.miriamsuzanne.com/123/"); 41 | }); -------------------------------------------------------------------------------- /transform.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {URL} from "url"; 3 | import {mapping} from "./mapping.js"; 4 | 5 | const lowercaseMapping = {}; 6 | for(let key in mapping) { 7 | lowercaseMapping[key.toLowerCase()] = mapping[key]; 8 | } 9 | 10 | function isFullUrl(url) { 11 | try { 12 | new URL(url); 13 | return true; 14 | } catch(e) { 15 | return false; 16 | } 17 | } 18 | 19 | function parseSource(source) { 20 | let urlObject = new URL(source); 21 | if(urlObject.hostname === "twitter.com") { 22 | let [noop, username, statusStr, statusId] = urlObject.pathname.split("/"); 23 | return { 24 | // normalize to lower case 25 | username: username.toLowerCase(), 26 | url: source, 27 | status: statusId, 28 | } 29 | } 30 | 31 | return { 32 | url: source 33 | }; 34 | } 35 | 36 | export function normalizeUrlSlashes(...args) { 37 | let joined = path.join(...args.filter(entry => !!entry)); 38 | return joined.split(path.sep).join("/"); 39 | } 40 | 41 | // source can be a path or a full tweet URL 42 | export function transform(source) { 43 | // passthrough 44 | if(!isFullUrl(source)) { 45 | return source; 46 | } 47 | 48 | let { username, status } = parseSource(source); 49 | 50 | if(username && lowercaseMapping[username]) { 51 | let urlObject = new URL(lowercaseMapping[username]); 52 | urlObject.pathname = normalizeUrlSlashes(urlObject.pathname, status); 53 | 54 | let urlString = urlObject.toString(); 55 | let hasTrailingSlash = source.endsWith("/"); 56 | 57 | return urlString + (!urlString.endsWith("/") && hasTrailingSlash ? "/" : ""); 58 | } 59 | 60 | return source; 61 | } --------------------------------------------------------------------------------