├── .editorconfig ├── .gitignore ├── .mailmap ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── export.html ├── index.html ├── package.json ├── patches ├── native-file-system-adapter@3.0.0.patch └── native-file-system-adapter@3.0.1.patch ├── pnpm-lock.yaml ├── postcss.config.js ├── scripts └── publish.sh ├── src ├── controllers │ ├── export-data-form.ts │ └── generate-archive-form.ts ├── main-export.ts ├── main-generator.ts ├── styles │ └── tailwind.css ├── templates │ ├── components │ │ ├── Embed.tsx │ │ ├── FeedPost.tsx │ │ ├── Page.tsx │ │ ├── PermalinkPost.tsx │ │ ├── ReplyPost.tsx │ │ ├── ReplyTree.tsx │ │ ├── RichTextRenderer.tsx │ │ └── embeds │ │ │ ├── EmbedFeed.tsx │ │ │ ├── EmbedImage.tsx │ │ │ ├── EmbedLink.tsx │ │ │ ├── EmbedList.tsx │ │ │ ├── EmbedNotFound.tsx │ │ │ └── EmbedPost.tsx │ ├── context.ts │ ├── intl │ │ ├── number.ts │ │ └── time.ts │ ├── pages │ │ ├── SearchPage.tsx │ │ ├── ThreadPage.tsx │ │ ├── TimelinePage.tsx │ │ └── WelcomePage.tsx │ └── utils │ │ ├── embed.ts │ │ ├── misc.ts │ │ ├── pagination.ts │ │ ├── path.ts │ │ ├── posts.ts │ │ ├── richtext │ │ ├── segmentize.ts │ │ ├── types.ts │ │ └── unicode.ts │ │ ├── timeline.ts │ │ └── url.ts ├── utils │ ├── async-pool.ts │ ├── controller.ts │ ├── format-bytes.ts │ ├── hashes.ts │ ├── logger.tsx │ ├── misc.ts │ ├── queue.ts │ └── tar.ts └── vite-env.d.ts ├── src_templates ├── scripts │ ├── dependencies │ │ └── hyperapp.js │ └── search.js └── styles │ ├── base.css │ ├── components │ ├── Embed.css │ ├── FeedPost.css │ ├── PermalinkPost.css │ ├── ReplyPost.css │ ├── ReplyTree.css │ └── embeds │ │ ├── EmbedFeed.css │ │ ├── EmbedImage.css │ │ ├── EmbedLink.css │ │ ├── EmbedList.css │ │ ├── EmbedNotFound.css │ │ └── EmbedPost.css │ ├── index.css │ ├── pages │ ├── SearchPage.css │ ├── ThreadPage.css │ ├── TimelinePage.css │ └── WelcomePage.css │ └── reset.css ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = tab 6 | trim_trailing_whitespace = true 7 | 8 | [*.yaml] 9 | indent_style = space 10 | 11 | [*.md] 12 | indent_style = space 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /deploy/ 2 | /node_modules/ 3 | /dist/ 4 | /.wireit/ 5 | 6 | /public/archive_assets/ 7 | 8 | .npm-*.log 9 | .pnpm-*.log 10 | .yarn-*.log 11 | npm-*.log 12 | pnpm-*.log 13 | yarn-*.log 14 | 15 | *.local 16 | 17 | tsconfig.tsbuildinfo 18 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Mary 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | publish-branch=trunk 2 | auto-install-peers=false 3 | @jsr:registry=https://npm.jsr.io 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "tabWidth": 2, 5 | "printWidth": 110, 6 | "semi": true, 7 | "singleQuote": true, 8 | "bracketSpacing": true, 9 | "plugins": ["prettier-plugin-tailwindcss"], 10 | "overrides": [ 11 | { 12 | "files": ["tsconfig.json", "jsconfig.json"], 13 | "options": { 14 | "parser": "jsonc" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["google.wireit"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | ```bash 4 | $ npm install # or pnpm install or yarn install 5 | ``` 6 | 7 | ### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) 8 | 9 | ## Available Scripts 10 | 11 | In the project directory, you can run: 12 | 13 | ### `npm run dev` 14 | 15 | Runs the app in the development mode.
16 | Open [http://localhost:5173](http://localhost:5173) to view it in the browser. 17 | 18 | ### `npm run build` 19 | 20 | Builds the app for production to the `dist` folder.
21 | It correctly bundles Solid in production mode and optimizes the build for the best performance. 22 | 23 | The build is minified and the filenames include the hashes.
24 | Your app is ready to be deployed! 25 | 26 | ## Deployment 27 | 28 | Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html) 29 | -------------------------------------------------------------------------------- /export.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Download Bluesky data export 7 | 8 | 9 | 10 |
11 |
16 |
17 |

Export Bluesky data

18 |

Enter your Bluesky handle or DID here to retrieve one.

19 |
20 | built by 22 | mary 26 | 27 | donate 28 | 29 | source code 32 |
33 |
34 | 35 | 41 | 42 | 43 | 55 | 56 |

57 | Note that these data exports aren't useful on its own, this site retrieves your repository as well 58 | as your uploaded media files, and stores it as is without any additional processing. It's intended 59 | to be consumed by other tools. 60 |

61 | 62 |
63 |
64 | 73 |
74 | 75 |
76 | 85 | 86 | 92 |
93 | 94 |
95 |
96 |
97 |
98 |
99 | 100 | 101 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | skeetgen - Bluesky archiving tool 7 | 8 | 9 | 10 |
11 |
16 |
17 |

skeetgen

18 |

Generate an easily viewable archive of your Bluesky posts

19 |
20 | built by 22 | mary 26 | 27 | donate 28 | 29 | source code 32 |
33 |
34 | 35 | 45 | 46 | 47 |
48 |
49 |
50 |
53 | 1 54 |
55 |
56 |
57 |

Select your data export

58 |

This will not be shared to any servers, any processing is done on your device.

59 | 60 |

61 | Don't have one yet? 62 | click here. 63 |

64 | 65 |
66 | 81 | 82 | No file selected. 85 |
86 | 87 | 101 |
102 |
103 | 104 | 141 | 142 | 167 | 168 |
169 |
170 |
173 | 2 174 |
175 |
176 | 177 |
178 |

Configure archive generation

179 | 180 |
181 |
182 | 191 | 192 |

193 | Turn this off if it takes too long to copy media files when generating from a 194 | media-heavy profile, you'd have to manually extract the 195 | blobs directory from the export over to the archive. 196 |

197 |
198 |
199 |
200 |
201 | 202 |
203 |
204 |
207 | 3 208 |
209 |
210 | 211 |
212 |

Are you ready?

213 | 214 |
215 | 221 |
222 | 223 |

224 |
225 |
226 |
227 |
228 |
229 |
230 | 231 | 232 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "pnpm@9.1.0", 3 | "private": true, 4 | "type": "module", 5 | "name": "bluesky-archiver", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && pnpm run gen && vite build", 9 | "publish": "pnpm run build && scripts/publish.sh", 10 | "gen": "wireit", 11 | "preview": "vite preview", 12 | "fmt": "prettier --cache --write ." 13 | }, 14 | "wireit": { 15 | "gen": { 16 | "dependencies": [ 17 | "gen:archive_css", 18 | "gen:archive_search_js" 19 | ] 20 | }, 21 | "gen:archive_css": { 22 | "command": "tailwindcss -i src_templates/styles/index.css -o public/archive_assets/style.css --minify", 23 | "files": [ 24 | "src_templates/styles/**/*.css" 25 | ], 26 | "output": [ 27 | "public/archive_assets/style.css" 28 | ] 29 | }, 30 | "gen:archive_search_js": { 31 | "command": "esbuild src_templates/scripts/search.js --bundle --format=iife --outfile=public/archive_assets/search.js --minify-syntax --minify-whitespace", 32 | "files": [ 33 | "src_templates/scripts/search.js", 34 | "src_templates/scripts/dependencies/**/*.js" 35 | ], 36 | "output": [ 37 | "public/archive_assets/search.js" 38 | ] 39 | } 40 | }, 41 | "dependencies": { 42 | "@akryum/flexsearch-es": "^0.7.32", 43 | "@ipld/car": "^5.3.0", 44 | "@ipld/dag-cbor": "^9.2.0", 45 | "@mary/bluesky-client": "npm:@jsr/mary__bluesky-client@^0.5.17", 46 | "multiformats": "^12.1.3", 47 | "native-file-system-adapter": "^3.0.1", 48 | "p-map": "^7.0.2" 49 | }, 50 | "devDependencies": { 51 | "@babel/core": "^7.24.6", 52 | "@babel/plugin-syntax-typescript": "^7.24.6", 53 | "@intrnl/jsx-to-string": "^0.1.6", 54 | "@minify-html/node": "0.11.1", 55 | "@rollup/plugin-babel": "^6.0.4", 56 | "@tailwindcss/forms": "^0.5.7", 57 | "autoprefixer": "^10.4.19", 58 | "esbuild": "^0.19.12", 59 | "postcss": "^8.4.38", 60 | "prettier": "^3.2.5", 61 | "prettier-plugin-tailwindcss": "^0.5.14", 62 | "tailwindcss": "^3.4.3", 63 | "terser": "^5.31.0", 64 | "typescript": "^5.4.5", 65 | "vite": "^5.2.11", 66 | "wireit": "^0.14.4" 67 | }, 68 | "pnpm": { 69 | "patchedDependencies": { 70 | "native-file-system-adapter@3.0.1": "patches/native-file-system-adapter@3.0.1.patch" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /patches/native-file-system-adapter@3.0.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/package.json b/package.json 2 | index 18c05781ac63bb3a47e799309756e65ea88f8332..3a08d2b45a420f41511c8254210ad6a3180d0f41 100644 3 | --- a/package.json 4 | +++ b/package.json 5 | @@ -24,7 +24,7 @@ 6 | "engines": { 7 | "node": ">=14.8.0" 8 | }, 9 | - "types": "/types/mod.d.ts", 10 | + "types": "./types/mod.d.ts", 11 | "keywords": [ 12 | "filesystem", 13 | "file", 14 | -------------------------------------------------------------------------------- /patches/native-file-system-adapter@3.0.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/package.json b/package.json 2 | index 91bafa41a8a9dfcd587826833f9ac8daad1dabb8..7bd6fa577c39cbf6604b6730aa866259bafacb9a 100644 3 | --- a/package.json 4 | +++ b/package.json 5 | @@ -24,7 +24,7 @@ 6 | "engines": { 7 | "node": ">=14.8.0" 8 | }, 9 | - "types": "types/mod.d.ts", 10 | + "types": "./types/mod.d.ts", 11 | "keywords": [ 12 | "filesystem", 13 | "file", 14 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -n $(git status --porcelain) ]]; then 6 | echo 'Working directory is not clean' 7 | git status --short 8 | exit 1 9 | fi 10 | 11 | GIT_COMMIT=$(git rev-parse HEAD) 12 | 13 | rsync -aHAX --delete --exclude=.git --exclude=.nojekyll dist/ deploy/ 14 | touch deploy/.nojekyll 15 | 16 | git -C deploy/ add . 17 | git -C deploy/ commit -m "deploy: ${GIT_COMMIT}" 18 | git -C deploy/ push 19 | -------------------------------------------------------------------------------- /src/controllers/export-data-form.ts: -------------------------------------------------------------------------------- 1 | import { showSaveFilePicker, type FileSystemFileHandle } from 'native-file-system-adapter'; 2 | import map_promises from 'p-map'; 3 | 4 | import { BskyXRPC, getPdsEndpoint, type DidDocument } from '@mary/bluesky-client'; 5 | import { ResponseType, XRPCError, type XRPCResponse } from '@mary/bluesky-client/xrpc'; 6 | 7 | import { Logger } from '../utils/logger.tsx'; 8 | 9 | import { target } from '../utils/controller.ts'; 10 | import { format_bytes } from '../utils/format-bytes.ts'; 11 | import { iterate_stream } from '../utils/misc.ts'; 12 | import { write_tar_entry } from '../utils/tar.ts'; 13 | 14 | type DID = `did:${string}`; 15 | 16 | const APPVIEW_URL = 'https://public.api.bsky.app'; 17 | const HOST_RE = /^([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]+))$/; 18 | 19 | const is_did = (str: string): str is DID => { 20 | return str.startsWith('did:'); 21 | }; 22 | 23 | const supports_fsa = 'showDirectoryPicker' in globalThis; 24 | 25 | class ExportDataForm extends HTMLElement { 26 | private form = target(this, 'form'); 27 | private logger = target(this, 'logger'); 28 | 29 | private fsa_warning = target(this, 'fsa_warning'); 30 | 31 | private object_url: string | undefined; 32 | 33 | handle_before_unload = (ev: BeforeUnloadEvent) => { 34 | ev.preventDefault(); 35 | ev.returnValue = true; 36 | }; 37 | 38 | connectedCallback() { 39 | if (!supports_fsa) { 40 | const $fsa_warning = this.fsa_warning.get()!; 41 | $fsa_warning.style.display = ''; 42 | } 43 | 44 | { 45 | const $form = this.form.get()!; 46 | 47 | let controller: AbortController | undefined; 48 | 49 | $form.addEventListener('submit', (ev) => { 50 | ev.preventDefault(); 51 | 52 | controller?.abort(); 53 | controller = new AbortController(); 54 | 55 | const data = new FormData($form); 56 | const identifier = (data.get('identifier') as string).replace(/^@/, ''); 57 | const with_media = !!data.get('with_media'); 58 | 59 | const signal = controller.signal; 60 | 61 | if (this.object_url) { 62 | URL.revokeObjectURL(this.object_url); 63 | this.object_url = undefined; 64 | } 65 | 66 | const date = new Date().toISOString(); 67 | 68 | const logger = new Logger(this.logger.get()!, signal); 69 | 70 | const promise = showSaveFilePicker({ 71 | suggestedName: `${date}-${identifier}.tar`, 72 | 73 | // @ts-expect-error - not sure why the polyfill isn't typed correctly. 74 | id: 'bluesky-export', 75 | startIn: 'downloads', 76 | types: [ 77 | { 78 | description: 'Tarball archive', 79 | accept: { 'application/x-tar': ['.tar'] }, 80 | }, 81 | ], 82 | }); 83 | 84 | promise.then( 85 | (fd) => { 86 | if (signal.aborted) { 87 | return; 88 | } 89 | 90 | window.addEventListener('beforeunload', this.handle_before_unload); 91 | 92 | this.download_archive(signal, logger, fd, identifier, with_media).then( 93 | () => { 94 | window.removeEventListener('beforeunload', this.handle_before_unload); 95 | }, 96 | (err) => { 97 | if (signal.aborted) { 98 | return; 99 | } 100 | 101 | window.removeEventListener('beforeunload', this.handle_before_unload); 102 | 103 | console.error(err); 104 | logger.error(err.message); 105 | }, 106 | ); 107 | }, 108 | (err) => { 109 | console.warn(err); 110 | 111 | if (err instanceof DOMException && err.name === 'AbortError') { 112 | logger.warn(`Opened the file picker, but it was aborted`); 113 | } else { 114 | logger.warn(`Something went wrong when opening the file picker`); 115 | } 116 | }, 117 | ); 118 | }); 119 | } 120 | } 121 | 122 | async download_archive( 123 | signal: AbortSignal, 124 | logger: Logger, 125 | fd: FileSystemFileHandle, 126 | identifier: string, 127 | with_media: boolean, 128 | ) { 129 | logger.log(`Data export started`); 130 | 131 | // 1. Resolve DID if it's not one 132 | let did: DID; 133 | { 134 | if (is_did(identifier)) { 135 | did = identifier; 136 | } else { 137 | using _progress = logger.progress(`Resolving ${identifier}`); 138 | 139 | const rpc = new BskyXRPC({ service: APPVIEW_URL }); 140 | 141 | const response = await rpc.get('com.atproto.identity.resolveHandle', { 142 | signal: signal, 143 | params: { 144 | handle: identifier, 145 | }, 146 | }); 147 | 148 | did = response.data.did; 149 | logger.log(`Resolved @${identifier} to ${did}`); 150 | } 151 | } 152 | 153 | // 2. Retrieve the DID document 154 | let doc: DidDocument; 155 | { 156 | const [, type, ...rest] = did.split(':'); 157 | const ident = rest.join(':'); 158 | 159 | if (type === 'plc') { 160 | using _progress = logger.progress(`Contacting PLC directory`); 161 | 162 | const response = await fetch(`https://plc.directory/${did}`, { signal: signal }); 163 | 164 | if (response.status === 404) { 165 | throw new Error(`DID not registered`); 166 | } else if (!response.ok) { 167 | throw new Error(`Unable to contact PLC directory, response error ${response.status}`); 168 | } 169 | 170 | doc = await response.json(); 171 | } else if (type === 'web') { 172 | if (!HOST_RE.test(ident)) { 173 | throw new Error(`Invalid did:web identifier: ${ident}`); 174 | } 175 | 176 | using _progress = logger.progress(`Contacting ${ident}`); 177 | 178 | let response: Response; 179 | try { 180 | response = await fetch(`https://${ident}/.well-known/did.json`, { 181 | signal: signal, 182 | }); 183 | } catch (err) { 184 | throw new Error(`Unable to retrieve DID document, is CORS set up properly?`); 185 | } 186 | 187 | if (response.status === 404) { 188 | throw new Error(`DID document not found`); 189 | } else if (!response.ok) { 190 | throw new Error(`Unable to retrieve DID document, response error ${response.status}`); 191 | } 192 | 193 | doc = await response.json(); 194 | } else { 195 | throw new Error(`Unsupported DID type: ${type}`); 196 | } 197 | } 198 | 199 | const pds = getPdsEndpoint(doc); 200 | 201 | if (!pds) { 202 | throw new Error(`This user is not registered to any Bluesky PDS.`); 203 | } 204 | 205 | logger.log(`User is located on ${new URL(pds).hostname}`); 206 | 207 | // 3. Download and write the files... 208 | const writable = await fd.createWritable({ keepExistingData: false }); 209 | 210 | try { 211 | // DID document 212 | { 213 | const entry = write_tar_entry({ 214 | filename: 'did.json', 215 | data: JSON.stringify(doc), 216 | }); 217 | 218 | logger.log(`Writing DID document to archive`); 219 | await writable.write(entry); 220 | } 221 | 222 | // Data repository 223 | { 224 | using progress = logger.progress(`Downloading repository`); 225 | 226 | const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`, { signal: signal }); 227 | const body = response.body; 228 | 229 | if (!response.ok || !body) { 230 | throw new Error(`Failed to retrieve the export`); 231 | } 232 | 233 | const chunks: Uint8Array[] = []; 234 | 235 | let size = 0; 236 | 237 | for await (const chunk of iterate_stream(body)) { 238 | size += chunk.length; 239 | chunks.push(chunk); 240 | 241 | if (!progress.ratelimited) { 242 | progress.update(`Downloading repository (${format_bytes(size)})`); 243 | } 244 | } 245 | 246 | const bytes = new Uint8Array(size); 247 | let offset = 0; 248 | 249 | for (let i = 0, il = chunks.length; i < il; i++) { 250 | const chunk = chunks[i]; 251 | 252 | bytes.set(chunk, offset); 253 | offset += chunk.length; 254 | } 255 | 256 | const entry = write_tar_entry({ 257 | filename: 'repo.car', 258 | data: bytes, 259 | }); 260 | 261 | logger.log(`Writing repository to archive (${format_bytes(size)})`); 262 | await writable.write(entry); 263 | 264 | signal.throwIfAborted(); 265 | } 266 | 267 | // Blobs 268 | if (with_media) { 269 | const rpc = new BskyXRPC({ service: pds }); 270 | 271 | let done = 0; 272 | let cids: string[] = []; 273 | let cursor: string | undefined; 274 | 275 | { 276 | using progress = logger.progress(`Retrieving list of blobs`, null); 277 | do { 278 | const response = await rpc.get('com.atproto.sync.listBlobs', { 279 | signal: any(signal, AbortSignal.timeout(90_000)), 280 | params: { 281 | did: did, 282 | cursor: cursor, 283 | limit: 1000, 284 | }, 285 | }); 286 | 287 | const data = response.data; 288 | 289 | cids = cids.concat(data.cids); 290 | cursor = data.cursor; 291 | 292 | progress.update(`Retrieving list of blobs (${cids.length} found)`); 293 | } while (cursor != null); 294 | } 295 | 296 | const total = cids.length; 297 | 298 | logger.log(`Found ${total} blobs to download`); 299 | 300 | { 301 | using progress = logger.progress(`Downloading blobs`); 302 | 303 | await map_promises( 304 | cids, 305 | async (cid) => { 306 | let response: XRPCResponse; 307 | let fails = 0; 308 | 309 | while (true) { 310 | try { 311 | response = await rpc.get('com.atproto.sync.getBlob', { 312 | signal: signal, 313 | params: { 314 | did: did, 315 | cid: cid, 316 | }, 317 | }); 318 | } catch (err) { 319 | if (signal.aborted) { 320 | return; 321 | } 322 | 323 | if (err instanceof XRPCError) { 324 | // we got ratelimited, let's cool down 325 | if (err.status === ResponseType.RateLimitExceeded) { 326 | const rl_reset = err.headers?.['ratelimit-reset']; 327 | 328 | if (rl_reset !== undefined) { 329 | logger.warn(`Ratelimit exceeded, waiting`); 330 | 331 | // `ratelimit-reset` is in unix 332 | const reset_date = +rl_reset * 1_000; 333 | const now = Date.now(); 334 | 335 | // add one second just to be sure 336 | const delta = reset_date - now + 1_000; 337 | 338 | await sleep(delta); 339 | continue; 340 | } 341 | } else if (err.status === ResponseType.InvalidRequest) { 342 | if (err.message === 'Blob not found') { 343 | logger.warn(`Tried to download nonexistent blob\n${cid}`); 344 | return; 345 | } 346 | } 347 | } 348 | 349 | // Retry 2 times before failing entirely. 350 | if (++fails < 3) { 351 | continue; 352 | } 353 | 354 | throw err; 355 | } 356 | 357 | break; 358 | } 359 | 360 | const segment = get_cid_segment(cid); 361 | 362 | const entry = write_tar_entry({ 363 | filename: `blobs/${segment}`, 364 | data: response.data as Uint8Array, 365 | }); 366 | 367 | await writable.write(entry); 368 | 369 | progress.update(`Downloading blobs (${++done} of ${total})`); 370 | }, 371 | { concurrency: 2, signal: signal }, 372 | ); 373 | } 374 | } 375 | 376 | logger.log(`Finishing up`); 377 | await writable.close(); 378 | } catch (err) { 379 | logger.log(`Aborting`); 380 | await writable.abort(); 381 | 382 | throw err; 383 | } 384 | 385 | logger.log(`Data export finished`); 386 | } 387 | } 388 | 389 | customElements.define('export-data-form', ExportDataForm); 390 | 391 | function get_cid_segment(cid: string) { 392 | // Use the first 8 characters as the bucket 393 | // Bluesky CIDs always starts with bafkrei (7 chars) 394 | const split = 8; 395 | 396 | return `${cid.slice(0, split)}/${cid.slice(split)}`; 397 | // return [cid.slice(0, split), cid.slice(8)] as const; 398 | } 399 | 400 | function sleep(ms: number) { 401 | return new Promise((resolve) => setTimeout(resolve, ms)); 402 | } 403 | 404 | function any(...signals: AbortSignal[]): AbortSignal { 405 | const controller = new AbortController(); 406 | const signal = controller.signal; 407 | 408 | for (let i = 0, il = signals.length; i < il; i++) { 409 | const dep = signals[i]; 410 | 411 | if (dep.aborted) { 412 | controller.abort(dep.reason); 413 | break; 414 | } 415 | 416 | dep.addEventListener('abort', () => controller.abort(dep.reason), { signal }); 417 | } 418 | 419 | return signal; 420 | } 421 | -------------------------------------------------------------------------------- /src/controllers/generate-archive-form.ts: -------------------------------------------------------------------------------- 1 | import { showSaveFilePicker, type FileSystemFileHandle } from 'native-file-system-adapter'; 2 | 3 | import type { DidDocument } from '@mary/bluesky-client'; 4 | import type { 5 | AppBskyActorProfile, 6 | AppBskyFeedGenerator, 7 | AppBskyFeedPost, 8 | AppBskyFeedThreadgate, 9 | AppBskyGraphList, 10 | At, 11 | } from '@mary/bluesky-client/lexicons'; 12 | 13 | import { CarBlockIterator } from '@ipld/car'; 14 | import { decode as decode_cbor } from '@ipld/dag-cbor'; 15 | import { CID } from 'multiformats/cid'; 16 | 17 | import { target } from '../utils/controller.ts'; 18 | import { assert, create_iterable_reader, iterate_stream } from '../utils/misc.ts'; 19 | import { untar, write_tar_entry } from '../utils/tar.ts'; 20 | 21 | import { get_blob_str, render_page, type BaseContext } from '../templates/context.ts'; 22 | import { chunked } from '../templates/utils/misc.ts'; 23 | import { create_posts_graph } from '../templates/utils/posts.ts'; 24 | import { get_tid_segment } from '../templates/utils/url.ts'; 25 | 26 | import { SearchPage } from '../templates/pages/SearchPage.tsx'; 27 | import { ThreadPage } from '../templates/pages/ThreadPage.tsx'; 28 | import { TimelinePage } from '../templates/pages/TimelinePage.tsx'; 29 | import { WelcomePage } from '../templates/pages/WelcomePage.tsx'; 30 | 31 | const supports_fsa = 'showDirectoryPicker' in globalThis; 32 | 33 | const decoder = new TextDecoder(); 34 | 35 | class GenerateArchiveForm extends HTMLElement { 36 | private form = target(this, 'form'); 37 | private status = target(this, 'status'); 38 | 39 | private picker_input = target(this, 'picker_input'); 40 | private picker_label = target(this, 'picker_label'); 41 | 42 | private fsa_large_warning = target(this, 'fsa_large_warning'); 43 | 44 | handle_before_unload = (ev: BeforeUnloadEvent) => { 45 | ev.preventDefault(); 46 | ev.returnValue = true; 47 | }; 48 | 49 | connectedCallback() { 50 | { 51 | const $picker_input = this.picker_input.get()!; 52 | const $picker_label = this.picker_label.get()!; 53 | 54 | const $fsa_large_warning = this.fsa_large_warning.get()!; 55 | 56 | $picker_input.addEventListener('input', () => { 57 | const files = $picker_input.files!; 58 | const file = files.length > 0 ? files[0] : undefined; 59 | 60 | $picker_label.textContent = file ? `${file.name}` : `No file selected.`; 61 | $fsa_large_warning.style.display = !supports_fsa && file && file.size > 1e7 ? '' : `none`; 62 | }); 63 | } 64 | 65 | { 66 | const $form = this.form.get()!; 67 | const $status = this.status.get()!; 68 | 69 | let controller: AbortController | undefined; 70 | 71 | $form.addEventListener('submit', (ev) => { 72 | ev.preventDefault(); 73 | 74 | controller?.abort(); 75 | controller = new AbortController(); 76 | 77 | const data = new FormData($form); 78 | const archive = data.get('archive') as File; 79 | const with_media = !!data.get('with_media'); 80 | 81 | const signal = controller.signal; 82 | 83 | $status.textContent = ''; 84 | $status.classList.remove('text-red-500'); 85 | $status.classList.add('opacity-50'); 86 | 87 | const date = new Date().toISOString(); 88 | 89 | const promise = showSaveFilePicker({ 90 | suggestedName: `${date}-archive.tar`, 91 | 92 | // @ts-expect-error - not sure why the polyfill isn't typed correctly. 93 | id: 'skeetgen-archive', 94 | startIn: 'downloads', 95 | types: [ 96 | { 97 | description: 'Tarball archive', 98 | accept: { 'application/x-tar': ['.tar'] }, 99 | }, 100 | ], 101 | }); 102 | 103 | promise.then((fd: FileSystemFileHandle) => { 104 | if (signal.aborted) { 105 | return; 106 | } 107 | 108 | window.addEventListener('beforeunload', this.handle_before_unload); 109 | 110 | this.generate_archive(signal, fd, archive, with_media).then( 111 | () => { 112 | // If we got here, we're still dealing with our own controller. 113 | controller!.abort(); 114 | 115 | window.removeEventListener('beforeunload', this.handle_before_unload); 116 | }, 117 | (err) => { 118 | if (signal.aborted) { 119 | return; 120 | } 121 | 122 | console.error(err); 123 | 124 | window.removeEventListener('beforeunload', this.handle_before_unload); 125 | 126 | $status.textContent = err.message; 127 | $status.classList.add('text-red-500'); 128 | $status.classList.remove('opacity-50'); 129 | }, 130 | ); 131 | }); 132 | }); 133 | } 134 | } 135 | 136 | async generate_archive(signal: AbortSignal, fd: FileSystemFileHandle, archive: Blob, with_media: boolean) { 137 | const $status = this.status.get()!; 138 | 139 | let did: DidDocument; 140 | let profile: AppBskyActorProfile.Record | undefined; 141 | 142 | const feeds = new Map(); 143 | const lists = new Map(); 144 | const posts = new Map(); 145 | const threadgates = new Map(); 146 | 147 | // 1. Retrieve posts from the archive 148 | { 149 | let car_buf: Uint8Array | undefined; 150 | let did_buf: Uint8Array | undefined; 151 | 152 | $status.textContent = `Reading archive...`; 153 | 154 | // Grab the DID document and repository CAR from the archive. 155 | { 156 | // Slice the archive so we can read it again for later. 157 | const stream = (with_media ? archive.slice() : archive).stream(); 158 | const reader = create_iterable_reader(iterate_stream(stream)); 159 | 160 | for await (const entry of untar(reader)) { 161 | if (entry.name === 'repo.car') { 162 | car_buf = new Uint8Array(entry.size); 163 | await entry.read(car_buf); 164 | } 165 | 166 | if (entry.name === 'did.json') { 167 | did_buf = new Uint8Array(entry.size); 168 | await entry.read(did_buf); 169 | } 170 | 171 | // Once we have these two there's no need to continue traversing 172 | if (car_buf !== undefined && did_buf !== undefined) { 173 | break; 174 | } 175 | } 176 | 177 | if (did_buf === undefined) { 178 | throw new Error(`did.json not found inside the archive`); 179 | } 180 | if (car_buf === undefined) { 181 | throw new Error(`repo.car not found inside the archive`); 182 | } 183 | } 184 | 185 | // Read the DID file 186 | { 187 | const doc_str = decoder.decode(did_buf); 188 | 189 | try { 190 | did = JSON.parse(doc_str) as DidDocument; 191 | } catch (err) { 192 | throw new Error(`failed to read did document`, { cause: err }); 193 | } 194 | } 195 | 196 | // Read the car file 197 | { 198 | const car = await CarBlockIterator.fromBytes(car_buf); 199 | 200 | const roots = await car.getRoots(); 201 | assert(roots.length === 1, `expected 1 root commit`); 202 | 203 | const root_cid = roots[0]; 204 | const blockmap: BlockMap = new Map(); 205 | 206 | for await (const { cid, bytes } of car) { 207 | // await verify_cid_for_bytes(cid, bytes); 208 | blockmap.set(cid.toString(), bytes); 209 | } 210 | 211 | signal.throwIfAborted(); 212 | 213 | const commit = read_obj(blockmap, root_cid) as Commit; 214 | 215 | for (const { key, cid } of walk_entries(blockmap, commit.data)) { 216 | const [collection, rkey] = key.split('/'); 217 | 218 | if (collection === 'app.bsky.feed.post') { 219 | const record = read_obj(blockmap, cid) as AppBskyFeedPost.Record; 220 | posts.set(rkey, record); 221 | } else if (collection === 'app.bsky.actor.profile') { 222 | const record = read_obj(blockmap, cid) as AppBskyActorProfile.Record; 223 | profile = record; 224 | } else if (collection === 'app.bsky.feed.generator') { 225 | const record = read_obj(blockmap, cid) as AppBskyFeedGenerator.Record; 226 | feeds.set(rkey, record); 227 | } else if (collection === 'app.bsky.graph.list') { 228 | const record = read_obj(blockmap, cid) as AppBskyGraphList.Record; 229 | lists.set(rkey, record); 230 | } else if (collection === 'app.bsky.feed.threadgate') { 231 | const record = read_obj(blockmap, cid) as AppBskyFeedThreadgate.Record; 232 | threadgates.set(rkey, record); 233 | } 234 | } 235 | } 236 | 237 | $status.textContent = `Retrieved ${posts.size} posts`; 238 | } 239 | 240 | // 3. Generate pages 241 | const writable = await fd.createWritable({ keepExistingData: false }); 242 | 243 | try { 244 | let base_context: BaseContext; 245 | 246 | // Set up the necessary context for rendering pages 247 | { 248 | const handles = did.alsoKnownAs?.filter((uri) => uri.startsWith('at://')).map((uri) => uri.slice(5)); 249 | 250 | base_context = { 251 | posts_dir: '/posts', 252 | blob_dir: `/blobs`, 253 | asset_dir: `/assets`, 254 | 255 | records: { 256 | feeds: feeds, 257 | lists: lists, 258 | posts: posts, 259 | threadgates: threadgates, 260 | }, 261 | post_graph: create_posts_graph(did.id as At.DID, posts), 262 | 263 | profile: { 264 | did: did.id as At.DID, 265 | handle: handles && handles.length > 0 ? handles[0] : 'handle.invalid', 266 | displayName: profile?.displayName?.trim(), 267 | avatar: profile?.avatar && get_blob_str(profile?.avatar), 268 | }, 269 | }; 270 | } 271 | 272 | // Render individual threads 273 | { 274 | signal.throwIfAborted(); 275 | $status.textContent = `Rendering threads`; 276 | 277 | for (const [rkey, post] of posts) { 278 | const segment = get_tid_segment(rkey); 279 | 280 | await writable.write( 281 | write_tar_entry({ 282 | filename: `posts/${segment}.html`, 283 | data: render_page({ 284 | context: { 285 | ...base_context, 286 | path: `/posts/${segment}.html`, 287 | }, 288 | render: () => { 289 | return ThreadPage({ post: post, rkey: rkey }); 290 | }, 291 | }), 292 | }), 293 | ); 294 | } 295 | } 296 | 297 | // Render timelines 298 | { 299 | signal.throwIfAborted(); 300 | $status.textContent = `Rendering timelines`; 301 | 302 | const post_tuples = [...posts]; 303 | 304 | // We want the posts to be sorted by newest-first 305 | { 306 | const collator = new Intl.Collator('en-US'); 307 | post_tuples.sort((a, b) => collator.compare(b[0], a[0])); 308 | } 309 | 310 | // All posts 311 | { 312 | await write_timeline_pages('with_replies', post_tuples); 313 | } 314 | 315 | // Root posts only 316 | { 317 | const root_posts = post_tuples.filter(([, post]) => post.reply === undefined); 318 | await write_timeline_pages('posts', root_posts); 319 | } 320 | 321 | // Image posts only 322 | { 323 | const media_posts = post_tuples.filter(([, post]) => { 324 | const embed = post.embed; 325 | 326 | return ( 327 | embed !== undefined && 328 | (embed.$type === 'app.bsky.embed.images' || 329 | (embed.$type === 'app.bsky.embed.recordWithMedia' && 330 | embed.media.$type === 'app.bsky.embed.images')) 331 | ); 332 | }); 333 | 334 | await write_timeline_pages('media', media_posts); 335 | } 336 | } 337 | 338 | // Render search page 339 | { 340 | signal.throwIfAborted(); 341 | $status.textContent = `Writing search page`; 342 | 343 | await writable.write( 344 | write_tar_entry({ 345 | filename: `search.html`, 346 | data: render_page({ 347 | context: { 348 | ...base_context, 349 | path: `/search.html`, 350 | }, 351 | render: () => { 352 | return SearchPage({}); 353 | }, 354 | }), 355 | }), 356 | ); 357 | } 358 | 359 | // Render other pages 360 | { 361 | signal.throwIfAborted(); 362 | $status.textContent = `Writing remaining pages`; 363 | 364 | await writable.write( 365 | write_tar_entry({ 366 | filename: `index.html`, 367 | data: render_page({ 368 | context: { 369 | ...base_context, 370 | path: `/index.html`, 371 | }, 372 | render: () => { 373 | return WelcomePage({}); 374 | }, 375 | }), 376 | }), 377 | ); 378 | } 379 | 380 | // Copy the necessary assets 381 | { 382 | signal.throwIfAborted(); 383 | $status.textContent = `Downloading necessary assets`; 384 | 385 | await writable.write( 386 | write_tar_entry({ 387 | filename: 'assets/style.css', 388 | data: await get_asset('archive_assets/style.css', signal), 389 | }), 390 | ); 391 | 392 | await writable.write( 393 | write_tar_entry({ 394 | filename: 'assets/search.js', 395 | data: await get_asset('archive_assets/search.js', signal), 396 | }), 397 | ); 398 | } 399 | 400 | // Copy all the blobs over, if requested 401 | if (with_media) { 402 | signal.throwIfAborted(); 403 | $status.textContent = `Copying media files`; 404 | 405 | let log = true; 406 | let count = 0; 407 | 408 | const stream = archive.stream(); 409 | const reader = create_iterable_reader(iterate_stream(stream)); 410 | 411 | for await (const entry of untar(reader)) { 412 | if (entry.name.startsWith('blobs/')) { 413 | signal.throwIfAborted(); 414 | 415 | const buffer = new Uint8Array(entry.size); 416 | 417 | await entry.read(buffer); 418 | await writable.write(write_tar_entry({ filename: entry.name, data: buffer })); 419 | 420 | count++; 421 | 422 | if (log) { 423 | log = false; 424 | $status.textContent = `Copying media files (${count} copied)`; 425 | 426 | setTimeout(() => (log = true), 500); 427 | } 428 | } 429 | } 430 | } 431 | 432 | async function get_asset(url: string, signal: AbortSignal) { 433 | const response = await fetch(import.meta.env.BASE_URL + url, { signal: signal }); 434 | 435 | if (!response.ok) { 436 | throw new Error(`Failed to retrieve ${url}`); 437 | } 438 | 439 | const buffer = await response.arrayBuffer(); 440 | 441 | return buffer; 442 | } 443 | 444 | async function write_timeline_pages( 445 | type: 'posts' | 'with_replies' | 'media', 446 | tuples: [rkey: string, post: AppBskyFeedPost.Record][], 447 | ) { 448 | const pages = chunked(tuples, 50); 449 | 450 | // Push an empty page 451 | if (pages.length === 0) { 452 | pages.push([]); 453 | } 454 | 455 | for (let i = 0, ilen = pages.length; i < ilen; i++) { 456 | const page = pages[i]; 457 | 458 | await writable.write( 459 | write_tar_entry({ 460 | filename: `timeline/${type}/${i + 1}.html`, 461 | data: render_page({ 462 | context: { 463 | ...base_context, 464 | path: `/timeline/${type}/${i + 1}.html`, 465 | }, 466 | render: () => { 467 | return TimelinePage({ 468 | type: type, 469 | current_page: i + 1, 470 | total_pages: ilen, 471 | posts: page, 472 | }); 473 | }, 474 | }), 475 | }), 476 | ); 477 | } 478 | } 479 | 480 | $status.textContent = `Waiting for writes to finish`; 481 | await writable.close(); 482 | } catch (err) { 483 | $status.textContent = `Aborting`; 484 | await writable.abort(err); 485 | 486 | throw err; 487 | } 488 | 489 | $status.textContent = `Archive generation finished`; 490 | } 491 | } 492 | 493 | customElements.define('generate-archive-form', GenerateArchiveForm); 494 | 495 | type BlockMap = Map; 496 | 497 | interface Commit { 498 | version: 3; 499 | did: string; 500 | data: CID; 501 | rev: string; 502 | prev: CID | null; 503 | sig: Uint8Array; 504 | } 505 | 506 | interface TreeEntry { 507 | /** count of bytes shared with previous TreeEntry in this Node (if any) */ 508 | p: number; 509 | /** remainder of key for this TreeEntry, after "prefixlen" have been removed */ 510 | k: Uint8Array; 511 | /** link to a sub-tree Node at a lower level which has keys sorting after this TreeEntry's key (to the "right"), but before the next TreeEntry's key in this Node (if any) */ 512 | v: CID; 513 | /** next subtree (to the right of leaf) */ 514 | t: CID | null; 515 | } 516 | 517 | interface MstNode { 518 | /** link to sub-tree Node on a lower level and with all keys sorting before keys at this node */ 519 | l: CID | null; 520 | /** ordered list of TreeEntry objects */ 521 | e: TreeEntry[]; 522 | } 523 | 524 | interface NodeEntry { 525 | key: string; 526 | cid: CID; 527 | } 528 | 529 | function* walk_entries(map: BlockMap, pointer: CID): Generator { 530 | const data = read_obj(map, pointer) as MstNode; 531 | const entries = data.e; 532 | 533 | let last_key = ''; 534 | 535 | if (data.l !== null) { 536 | yield* walk_entries(map, data.l); 537 | } 538 | 539 | for (let i = 0, il = entries.length; i < il; i++) { 540 | const entry = entries[i]; 541 | 542 | const key_str = decoder.decode(entry.k); 543 | const key = last_key.slice(0, entry.p) + key_str; 544 | 545 | last_key = key; 546 | 547 | yield { key: key, cid: entry.v }; 548 | 549 | if (entry.t !== null) { 550 | yield* walk_entries(map, entry.t); 551 | } 552 | } 553 | } 554 | 555 | // async function verify_cid_for_bytes(cid: CID, bytes: Uint8Array) { 556 | // const digest = await mf_sha256.digest(bytes); 557 | // const expected = CID.createV1(cid.code, digest); 558 | 559 | // if (!cid.equals(expected)) { 560 | // throw new Error(`Invalid CID, expected ${expected} but got ${cid}`); 561 | // } 562 | // } 563 | 564 | function read_obj(map: Map, cid: CID) { 565 | const bytes = map.get(cid.toString()); 566 | assert(bytes != null, `cid not found`); 567 | 568 | const data = decode_cbor(bytes); 569 | 570 | return data; 571 | } 572 | -------------------------------------------------------------------------------- /src/main-export.ts: -------------------------------------------------------------------------------- 1 | import './styles/tailwind.css'; 2 | 3 | import './controllers/export-data-form.ts'; 4 | 5 | const fieldset = document.getElementById('fieldset') as HTMLFieldSetElement; 6 | fieldset.disabled = false; 7 | -------------------------------------------------------------------------------- /src/main-generator.ts: -------------------------------------------------------------------------------- 1 | import './styles/tailwind.css'; 2 | 3 | import './controllers/generate-archive-form.ts'; 4 | 5 | const fieldset = document.getElementById('fieldset') as HTMLFieldSetElement; 6 | fieldset.disabled = false; 7 | -------------------------------------------------------------------------------- /src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/templates/components/Embed.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_page_context } from '../context.ts'; 4 | import { get_collection_ns, get_record_key, get_repo_id } from '../utils/url.ts'; 5 | 6 | import type { EmbeddedImage, EmbeddedLink, EmbeddedRecord } from '../utils/embed.ts'; 7 | 8 | import EmbedFeed from './embeds/EmbedFeed.tsx'; 9 | import EmbedImage from './embeds/EmbedImage.tsx'; 10 | import EmbedLink from './embeds/EmbedLink.tsx'; 11 | import EmbedList from './embeds/EmbedList.tsx'; 12 | import EmbedNotFound from './embeds/EmbedNotFound.tsx'; 13 | import EmbedPost from './embeds/EmbedPost.tsx'; 14 | 15 | export interface EmbedProps { 16 | embed: NonNullable; 17 | large: boolean; 18 | } 19 | 20 | function Embed({ embed, large }: EmbedProps) { 21 | let images: EmbeddedImage[] | undefined; 22 | let link: EmbeddedLink | undefined; 23 | let record: EmbeddedRecord | undefined; 24 | 25 | { 26 | const $type = embed.$type; 27 | 28 | if ($type == 'app.bsky.embed.external') { 29 | link = embed.external; 30 | } else if ($type === 'app.bsky.embed.images') { 31 | images = embed.images; 32 | } else if ($type === 'app.bsky.embed.record') { 33 | record = embed.record; 34 | } else if ($type === 'app.bsky.embed.recordWithMedia') { 35 | const rec = embed.record.record; 36 | 37 | const media = embed.media; 38 | const mediatype = media.$type; 39 | 40 | record = rec; 41 | 42 | if (mediatype === 'app.bsky.embed.external') { 43 | link = media.external; 44 | } else if (mediatype === 'app.bsky.embed.images') { 45 | images = media.images; 46 | } 47 | } 48 | } 49 | 50 | return ( 51 |
52 | {link ? : null} 53 | {images ? : null} 54 | {record ? render_record(record, large) : null} 55 |
56 | ); 57 | } 58 | 59 | export default Embed; 60 | 61 | function render_record(record: EmbeddedRecord, large: boolean) { 62 | const { profile, records } = get_page_context(); 63 | 64 | const uri = record.uri; 65 | 66 | const ns = get_collection_ns(uri); 67 | const rkey = get_record_key(uri); 68 | 69 | const is_same_author = get_repo_id(uri) === profile.did; 70 | 71 | if (ns === 'app.bsky.feed.post') { 72 | if (is_same_author) { 73 | const post = records.posts.get(rkey); 74 | 75 | if (post !== undefined) { 76 | return ; 77 | } 78 | } 79 | 80 | return ; 81 | } 82 | 83 | if (ns === 'app.bsky.feed.generator') { 84 | if (is_same_author) { 85 | const feed = records.feeds.get(rkey); 86 | 87 | if (feed !== undefined) { 88 | return ; 89 | } 90 | } 91 | 92 | return ; 93 | } 94 | 95 | if (ns === 'app.bsky.graph.list') { 96 | if (is_same_author) { 97 | const list = records.lists.get(rkey); 98 | 99 | if (list !== undefined) { 100 | return ; 101 | } 102 | } 103 | 104 | return ; 105 | } 106 | 107 | return null; 108 | } 109 | -------------------------------------------------------------------------------- /src/templates/components/FeedPost.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_page_context } from '../context.ts'; 4 | import { format_abs_date, format_abs_date_time } from '../intl/time.ts'; 5 | import { get_blob_url, get_post_url } from '../utils/url.ts'; 6 | 7 | import Embed from './Embed.tsx'; 8 | import RichTextRenderer from './RichTextRenderer.tsx'; 9 | 10 | export interface FeedPost { 11 | rkey: string; 12 | post: AppBskyFeedPost.Record; 13 | /** Changes the condition for reply counter display from >1 to >0 */ 14 | always_show_replies: boolean; 15 | /** Post is connected to a parent */ 16 | has_prev: boolean; 17 | /** Draw a line connecting this post to the next */ 18 | has_next: boolean; 19 | } 20 | 21 | function FeedPost({ rkey, post, always_show_replies, has_prev, has_next }: FeedPost) { 22 | const ctx = get_page_context(); 23 | const href = get_post_url(rkey); 24 | 25 | let reply_count = 0; 26 | { 27 | const entry = ctx.post_graph.get(rkey); 28 | if (entry !== undefined) { 29 | reply_count = entry.descendants.length; 30 | } 31 | } 32 | 33 | return ( 34 |
35 |
36 | {!has_prev && post.reply !== undefined ? ( 37 | 43 | ) : null} 44 |
45 | 46 |
47 |
48 |
49 | {ctx.profile.avatar ? ( 50 | 51 | ) : null} 52 |
53 | 54 | {has_next ?
: null} 55 |
56 | 57 |
58 |
59 | 60 | {ctx.profile.displayName ? ( 61 | 62 | {ctx.profile.displayName} 63 | 64 | ) : ( 65 | @{ctx.profile.handle} 66 | )} 67 | 68 | 69 | 72 | 73 | 74 | 75 | 76 |
77 | 78 |
79 | 80 |
81 | 82 | {post.embed ? : null} 83 | 84 | {reply_count > (always_show_replies ? 0 : 1) && ( 85 | 86 | {reply_count} replies 87 | 88 | )} 89 |
90 |
91 |
92 | ); 93 | } 94 | 95 | export default FeedPost; 96 | -------------------------------------------------------------------------------- /src/templates/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXNode } from '@intrnl/jsx-to-string'; 2 | 3 | import { get_asset_url, get_relative_url } from '../utils/url.ts'; 4 | 5 | export interface PageProps { 6 | title?: string; 7 | children?: JSXNode; 8 | head?: JSXNode; 9 | } 10 | 11 | function Page({ title, children, head }: PageProps) { 12 | return ( 13 | 14 | 15 | 16 | 17 | {title} 18 | 19 | {head} 20 | 21 | 22 |
23 |
24 | 35 | {children} 36 |
37 |
38 | 39 | 40 | ); 41 | } 42 | 43 | export default Page; 44 | -------------------------------------------------------------------------------- /src/templates/components/PermalinkPost.tsx: -------------------------------------------------------------------------------- 1 | import { repeat } from '@intrnl/jsx-to-string'; 2 | 3 | import type { AppBskyFeedPost } from '@mary/bluesky-client/lexicons'; 4 | 5 | import { get_page_context } from '../context.ts'; 6 | import { format_abs_date_time } from '../intl/time.ts'; 7 | import { get_blob_url } from '../utils/url.ts'; 8 | 9 | import Embed from './Embed.tsx'; 10 | import RichTextRenderer from './RichTextRenderer.tsx'; 11 | 12 | interface PermalinkPostProps { 13 | post: AppBskyFeedPost.Record; 14 | } 15 | 16 | function PermalinkPost({ post }: PermalinkPostProps) { 17 | const ctx = get_page_context(); 18 | 19 | return ( 20 | 59 | ); 60 | } 61 | 62 | export default PermalinkPost; 63 | -------------------------------------------------------------------------------- /src/templates/components/ReplyPost.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_page_context } from '../context.ts'; 4 | import { format_abs_date, format_abs_date_time } from '../intl/time.ts'; 5 | import { get_blob_url, get_post_url } from '../utils/url.ts'; 6 | 7 | import Embed from './Embed.tsx'; 8 | import RichTextRenderer from './RichTextRenderer.tsx'; 9 | 10 | export interface ReplyPostProps { 11 | rkey: string; 12 | post: AppBskyFeedPost.Record; 13 | has_children: boolean; 14 | has_parent: boolean; 15 | } 16 | 17 | function ReplyPost({ rkey, post, has_children, has_parent }: ReplyPostProps) { 18 | const ctx = get_page_context(); 19 | 20 | return ( 21 |
22 |
23 |
24 | {ctx.profile.avatar ? ( 25 | 26 | ) : null} 27 |
28 | 29 | {has_children ?
: null} 30 | {has_parent ?
: null} 31 |
32 | 33 |
34 |
35 | 36 | {ctx.profile.displayName ? ( 37 | 38 | {ctx.profile.displayName} 39 | 40 | ) : ( 41 | @{ctx.profile.handle} 42 | )} 43 | 44 | 45 | 48 | 49 | 54 | 55 | 56 |
57 | 58 |
59 | 60 |
61 | 62 | {post.embed ? : null} 63 |
64 |
65 | ); 66 | } 67 | 68 | export default ReplyPost; 69 | -------------------------------------------------------------------------------- /src/templates/components/ReplyTree.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost } from '@mary/bluesky-client/lexicons'; 2 | import { repeat } from '@intrnl/jsx-to-string'; 3 | 4 | import { get_page_context } from '../context.ts'; 5 | import { get_post_url } from '../utils/url.ts'; 6 | 7 | import ReplyPost from './ReplyPost.tsx'; 8 | 9 | export interface ReplyTreeProps { 10 | rkey: string; 11 | post: AppBskyFeedPost.Record; 12 | depth: number; 13 | has_next: boolean; 14 | } 15 | 16 | const MOBILE_DEPTH_LIMIT = 3; 17 | const DESKTOP_DEPTH_LIMIT = 7; 18 | 19 | function ReplyTree({ rkey, post, depth, has_next }: ReplyTreeProps) { 20 | const ctx = get_page_context(); 21 | const children: [rkey: string, post: AppBskyFeedPost.Record][] = []; 22 | 23 | { 24 | const entry = ctx.post_graph.get(rkey); 25 | if (entry !== undefined) { 26 | const posts = ctx.records.posts; 27 | const descendants = entry.descendants; 28 | 29 | for (let i = 0, ilen = descendants.length; i < ilen; i++) { 30 | const child_rkey = descendants[i]; 31 | const child_post = posts.get(child_rkey); 32 | 33 | if (child_post !== undefined) { 34 | children.push([child_rkey, child_post]); 35 | } 36 | } 37 | } 38 | } 39 | 40 | const render_children = () => { 41 | return repeat(children, ([child_rkey, child_post], index) => ( 42 | 48 | )); 49 | }; 50 | 51 | const render_has_more = () => { 52 | return ( 53 | 60 | ); 61 | }; 62 | 63 | return ( 64 |
65 | {has_next ?
: null} 66 | 67 | 0} has_parent={depth > 0} /> 68 | 69 | {children.length > 0 && ( 70 |
71 | {depth >= DESKTOP_DEPTH_LIMIT ? ( 72 | render_has_more() 73 | ) : depth === MOBILE_DEPTH_LIMIT ? ( 74 | // match exactly so that we only render this once 75 | <> 76 |
{render_has_more()}
77 |
{render_children()}
78 | 79 | ) : ( 80 | render_children() 81 | )} 82 |
83 | )} 84 |
85 | ); 86 | } 87 | 88 | export default ReplyTree; 89 | -------------------------------------------------------------------------------- /src/templates/components/RichTextRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { repeat, type TrustedHTML } from '@intrnl/jsx-to-string'; 2 | 3 | import { segment_richtext } from '../utils/richtext/segmentize.ts'; 4 | import type { Facet } from '../utils/richtext/types.ts'; 5 | import { get_bsky_app_url } from '../utils/url.ts'; 6 | 7 | export interface RichTextRendererProps { 8 | text: string; 9 | facets: Facet[] | undefined; 10 | } 11 | 12 | const cached = new WeakMap(); 13 | 14 | function RichTextRenderer({ text, facets }: RichTextRendererProps) { 15 | if (facets !== undefined) { 16 | let rendered = cached.get(facets); 17 | 18 | if (rendered === undefined) { 19 | const segments = segment_richtext(text, facets); 20 | 21 | const nodes = repeat(segments, (segment) => { 22 | const text = segment.text; 23 | 24 | const link = segment.link; 25 | const mention = segment.mention; 26 | const tag = segment.tag; 27 | 28 | if (link) { 29 | return ( 30 | 31 | {text} 32 | 33 | ); 34 | } else if (mention) { 35 | return ( 36 | 42 | {text} 43 | 44 | ); 45 | } else if (tag) { 46 | return {text}; 47 | } 48 | 49 | return text; 50 | }); 51 | 52 | cached.set(facets, (rendered = <>{nodes})); 53 | } 54 | 55 | return rendered; 56 | } 57 | 58 | return <>{text}; 59 | } 60 | 61 | export default RichTextRenderer; 62 | -------------------------------------------------------------------------------- /src/templates/components/embeds/EmbedFeed.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedGenerator } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_blob_str } from '../../context.ts'; 4 | import { get_blob_url } from '../../utils/url.ts'; 5 | 6 | export interface EmbedFeedProps { 7 | record: AppBskyFeedGenerator.Record; 8 | } 9 | 10 | function EmbedFeed({ record }: EmbedFeedProps) { 11 | return ( 12 |
13 |
14 | {record.avatar ? ( 15 | 16 | ) : null} 17 |
18 | 19 |
20 |

{record.displayName}

21 |

Feed

22 |
23 |
24 | ); 25 | } 26 | 27 | export default EmbedFeed; 28 | -------------------------------------------------------------------------------- /src/templates/components/embeds/EmbedImage.tsx: -------------------------------------------------------------------------------- 1 | import { get_blob_str } from '../../context.ts'; 2 | import { get_blob_url } from '../../utils/url.ts'; 3 | 4 | import type { EmbeddedImage } from '../../utils/embed.ts'; 5 | 6 | export interface EmbedImageProps { 7 | images: EmbeddedImage[]; 8 | is_bordered: boolean; 9 | allow_standalone_ratio: boolean; 10 | } 11 | 12 | const enum RenderMode { 13 | MULTIPLE, 14 | STANDALONE, 15 | STANDALONE_RATIO, 16 | } 17 | 18 | function EmbedImage({ images, is_bordered, allow_standalone_ratio }: EmbedImageProps) { 19 | const length = images.length; 20 | const is_standalone_image = allow_standalone_ratio && length === 1 && 'aspectRatio' in images[0]; 21 | 22 | return ( 23 |
30 | {is_standalone_image ? ( 31 | render_img(images[0], RenderMode.STANDALONE_RATIO) 32 | ) : length === 1 ? ( 33 | render_img(images[0], RenderMode.STANDALONE) 34 | ) : length === 2 ? ( 35 |
36 |
{render_img(images[0], RenderMode.MULTIPLE)}
37 |
{render_img(images[1], RenderMode.MULTIPLE)}
38 |
39 | ) : length === 3 ? ( 40 |
41 |
42 | {render_img(images[0], RenderMode.MULTIPLE)} 43 | {render_img(images[1], RenderMode.MULTIPLE)} 44 |
45 | 46 |
{render_img(images[2], RenderMode.MULTIPLE)}
47 |
48 | ) : length === 4 ? ( 49 |
50 |
51 | {render_img(images[0], RenderMode.MULTIPLE)} 52 | {render_img(images[2], RenderMode.MULTIPLE)} 53 |
54 | 55 |
56 | {render_img(images[1], RenderMode.MULTIPLE)} 57 | {render_img(images[3], RenderMode.MULTIPLE)} 58 |
59 |
60 | ) : null} 61 |
62 | ); 63 | } 64 | 65 | export default EmbedImage; 66 | 67 | function render_img(img: EmbeddedImage, mode: RenderMode) { 68 | // FIXME: with STANDALONE_RATIO, we are resizing the image to make it fit 69 | // the container with our given constraints, but this doesn't work when the 70 | // image hasn't had its metadata loaded yet, the browser will snap to the 71 | // smallest possible size for our layout. 72 | 73 | const alt = img.alt; 74 | const aspectRatio = img.aspectRatio; 75 | 76 | let cn: string | undefined; 77 | let ratio: string | undefined; 78 | 79 | if (mode === RenderMode.MULTIPLE) { 80 | cn = `EmbedImage__imageContainer--multiple`; 81 | } else if (mode === RenderMode.STANDALONE) { 82 | cn = `EmbedImage__imageContainer--standalone`; 83 | } else if (mode === RenderMode.STANDALONE_RATIO) { 84 | cn = `EmbedImage__imageContainer--standaloneRatio`; 85 | ratio = `${aspectRatio!.width}/${aspectRatio!.height}`; 86 | } 87 | 88 | return ( 89 |
90 | {alt} 91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/templates/components/embeds/EmbedLink.tsx: -------------------------------------------------------------------------------- 1 | import { get_blob_str } from '../../context.ts'; 2 | import { get_blob_url } from '../../utils/url.ts'; 3 | 4 | import type { EmbeddedLink } from '../../utils/embed.ts'; 5 | 6 | export interface EmbedLinkProps { 7 | link: EmbeddedLink; 8 | } 9 | 10 | function EmbedLink({ link }: EmbedLinkProps) { 11 | return ( 12 | 13 | {link.thumb && ( 14 | 15 | )} 16 | 17 | 25 | 26 | ); 27 | } 28 | 29 | export default EmbedLink; 30 | 31 | function get_domain(url: string) { 32 | try { 33 | const host = new URL(url).host; 34 | return host.startsWith('www.') ? host.slice(4) : host; 35 | } catch { 36 | return url; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/templates/components/embeds/EmbedList.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyGraphDefs, AppBskyGraphList } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_blob_str } from '../../context.ts'; 4 | import { get_blob_url } from '../../utils/url.ts'; 5 | 6 | const LIST_PURPOSE_LABELS: Record = { 7 | 'app.bsky.graph.defs#modlist': 'Moderation list', 8 | 'app.bsky.graph.defs#curatelist': 'Curation list', 9 | }; 10 | 11 | export interface EmbedListProps { 12 | record: AppBskyGraphList.Record; 13 | } 14 | 15 | function EmbedList({ record }: EmbedListProps) { 16 | const raw_purpose = record.purpose; 17 | const purpose = raw_purpose in LIST_PURPOSE_LABELS ? LIST_PURPOSE_LABELS[raw_purpose] : raw_purpose; 18 | 19 | return ( 20 |
21 |
22 | {record.avatar ? ( 23 | 24 | ) : null} 25 |
26 | 27 |
28 |

{record.name}

29 |

{purpose}

30 |
31 |
32 | ); 33 | } 34 | 35 | export default EmbedList; 36 | -------------------------------------------------------------------------------- /src/templates/components/embeds/EmbedNotFound.tsx: -------------------------------------------------------------------------------- 1 | import type { At } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_page_context } from '../../context.ts'; 4 | import { get_bsky_app_url, get_collection_ns, get_repo_id } from '../../utils/url.ts'; 5 | 6 | export interface EmbedNotFoundProps { 7 | uri: string; 8 | } 9 | 10 | const COLLECTION_LABELS: Record = { 11 | 'app.bsky.feed.post': 'post', 12 | 'app.bsky.feed.generator': 'feed', 13 | 'app.bsky.graph.list': 'list', 14 | }; 15 | 16 | function EmbedNotFound({ uri }: EmbedNotFoundProps) { 17 | const ctx = get_page_context(); 18 | 19 | const repo = get_repo_id(uri) as At.DID; 20 | const ns = get_collection_ns(uri); 21 | 22 | const ns_label = ns in COLLECTION_LABELS ? COLLECTION_LABELS[ns] : `record (${ns})`; 23 | 24 | return ( 25 |
26 | {repo === ctx.profile.did ? ( 27 |

This {ns_label} may have been deleted

28 | ) : ( 29 | <> 30 |

This is a {ns_label} by another user.

31 | 32 | View in bsky.app 33 | 34 | 35 | )} 36 |
37 | ); 38 | } 39 | 40 | export default EmbedNotFound; 41 | -------------------------------------------------------------------------------- /src/templates/components/embeds/EmbedPost.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_page_context } from '../../context.ts'; 4 | import { format_abs_date } from '../../intl/time.ts'; 5 | import { get_blob_url, get_post_url } from '../../utils/url.ts'; 6 | 7 | import EmbedImage from './EmbedImage.tsx'; 8 | 9 | export interface EmbedPostProps { 10 | rkey: string; 11 | record: AppBskyFeedPost.Record; 12 | large: boolean; 13 | } 14 | 15 | function EmbedPost({ rkey, record, large }: EmbedPostProps) { 16 | const ctx = get_page_context(); 17 | 18 | const text = record.text; 19 | const images = get_post_images(record); 20 | 21 | const show_large_images = images !== undefined && (large || !text); 22 | 23 | return ( 24 | 25 |
26 |
27 | {ctx.profile.avatar ? ( 28 | 29 | ) : null} 30 |
31 | 32 | 33 | {ctx.profile.displayName ? ( 34 | 35 | {ctx.profile.displayName} 36 | 37 | ) : ( 38 | @{ctx.profile.handle} 39 | )} 40 | 41 | 42 | 45 | 46 | {format_abs_date(record.createdAt)} 47 |
48 | 49 | {text ? ( 50 |
51 | {images && !large ? ( 52 |
53 | 54 |
55 | ) : null} 56 | 57 |
{text}
58 |
59 | ) : null} 60 | 61 | {show_large_images ? ( 62 | <> 63 | {text ?
: null} 64 | 65 | 66 | ) : null} 67 |
68 | ); 69 | } 70 | 71 | export default EmbedPost; 72 | 73 | function get_post_images(post: AppBskyFeedPost.Record) { 74 | const embed = post.embed; 75 | 76 | if (embed) { 77 | const $type = embed.$type; 78 | 79 | if ($type === 'app.bsky.embed.images') { 80 | return embed.images; 81 | } else if ($type === 'app.bsky.embed.recordWithMedia') { 82 | const media = embed.media; 83 | 84 | if (media.$type === 'app.bsky.embed.images') { 85 | return media.images; 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/templates/context.ts: -------------------------------------------------------------------------------- 1 | import type { TrustedHTML } from '@intrnl/jsx-to-string'; 2 | 3 | import type { 4 | AppBskyActorDefs, 5 | AppBskyFeedGenerator, 6 | AppBskyFeedPost, 7 | AppBskyFeedThreadgate, 8 | AppBskyGraphList, 9 | At, 10 | } from '@mary/bluesky-client/lexicons'; 11 | 12 | import { CID } from 'multiformats/cid'; 13 | 14 | import type { PostGraphEntry } from './utils/posts.ts'; 15 | 16 | let curr_context: PageContext | undefined; 17 | 18 | export interface BaseContext { 19 | posts_dir: string; 20 | blob_dir: string; 21 | asset_dir: string; 22 | 23 | profile: AppBskyActorDefs.ProfileViewBasic; 24 | 25 | records: { 26 | feeds: Map; 27 | lists: Map; 28 | posts: Map; 29 | threadgates: Map; 30 | }; 31 | 32 | post_graph: Map; 33 | } 34 | 35 | export interface PageContext extends BaseContext { 36 | path: string; 37 | } 38 | 39 | export interface RenderPageOptions { 40 | context: PageContext; 41 | render: () => TrustedHTML; 42 | } 43 | 44 | export function render_page({ context, render }: RenderPageOptions): string { 45 | const prev_context = curr_context; 46 | 47 | try { 48 | curr_context = context; 49 | return '' + render().value; 50 | } finally { 51 | curr_context = prev_context; 52 | } 53 | } 54 | 55 | export function get_page_context(): PageContext { 56 | return curr_context!; 57 | } 58 | 59 | export function get_blob_str(blob: At.Blob) { 60 | const ref = CID.asCID(blob.ref); 61 | 62 | if (ref !== null) { 63 | return ref.toString(); 64 | } 65 | 66 | // Old blob interface 67 | if ('cid' in blob) { 68 | return blob.cid as string; 69 | } 70 | 71 | return blob.ref.$link; 72 | } 73 | 74 | export function is_did(str: At.DID): str is At.DID { 75 | return str.startsWith('did:'); 76 | } 77 | -------------------------------------------------------------------------------- /src/templates/intl/number.ts: -------------------------------------------------------------------------------- 1 | const long = new Intl.NumberFormat('en-US'); 2 | const compact = new Intl.NumberFormat('en-US', { notation: 'compact' }); 3 | 4 | export function format_compact(value: number) { 5 | if (value < 1_000) { 6 | return '' + value; 7 | } 8 | 9 | if (value < 100_000) { 10 | return long.format(value); 11 | } 12 | 13 | return compact.format(value); 14 | } 15 | 16 | export function format_long(value: number) { 17 | if (value < 1_000) { 18 | return '' + value; 19 | } 20 | 21 | return long.format(value); 22 | } 23 | -------------------------------------------------------------------------------- /src/templates/intl/time.ts: -------------------------------------------------------------------------------- 1 | const SECOND = 1e3; 2 | const NOW = SECOND * 5; 3 | const MINUTE = SECOND * 60; 4 | const HOUR = MINUTE * 60; 5 | const DAY = HOUR * 24; 6 | const WEEK = DAY * 7; 7 | const MONTH = WEEK * 4; 8 | const YEAR = MONTH * 12; 9 | 10 | const abs_with_time = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeStyle: 'short' }); 11 | const abs_with_year = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }); 12 | const abs = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }); 13 | 14 | const formatters: Record = {}; 15 | 16 | const is_nan = Number.isNaN; 17 | 18 | export function format_rel_time(time: string | number, base = new Date()) { 19 | const date = new Date(time); 20 | const num = date.getTime(); 21 | 22 | if (is_nan(num)) { 23 | return 'N/A'; 24 | } 25 | 26 | const delta = Math.abs(num - base.getTime()); 27 | 28 | if (delta > WEEK) { 29 | // if it's the same year, let's skip showing the year. 30 | if (date.getFullYear() === base.getFullYear()) { 31 | return abs.format(date); 32 | } 33 | 34 | return abs_with_year.format(date); 35 | } 36 | 37 | if (delta < NOW) { 38 | return `now`; 39 | } 40 | 41 | const [value, unit] = lookup_reltime(delta); 42 | 43 | const formatter = (formatters[unit] ||= new Intl.NumberFormat('en-US', { 44 | style: 'unit', 45 | unit: unit, 46 | unitDisplay: 'narrow', 47 | })); 48 | 49 | return formatter.format(Math.abs(value)); 50 | } 51 | 52 | export function format_abs_date(time: string | number) { 53 | const date = new Date(time); 54 | 55 | if (is_nan(date.getTime())) { 56 | return 'N/A'; 57 | } 58 | 59 | return abs_with_year.format(date); 60 | } 61 | 62 | export function format_abs_date_time(time: string | number) { 63 | const date = new Date(time); 64 | 65 | if (is_nan(date.getTime())) { 66 | return 'N/A'; 67 | } 68 | 69 | return abs_with_time.format(date); 70 | } 71 | 72 | export function lookup_reltime(delta: number): [value: number, unit: Intl.RelativeTimeFormatUnit] { 73 | if (delta < SECOND) { 74 | return [0, 'second']; 75 | } 76 | 77 | if (delta < MINUTE) { 78 | return [Math.trunc(delta / SECOND), 'second']; 79 | } 80 | 81 | if (delta < HOUR) { 82 | return [Math.trunc(delta / MINUTE), 'minute']; 83 | } 84 | 85 | if (delta < DAY) { 86 | return [Math.trunc(delta / HOUR), 'hour']; 87 | } 88 | 89 | if (delta < WEEK) { 90 | return [Math.trunc(delta / DAY), 'day']; 91 | } 92 | 93 | if (delta < MONTH) { 94 | return [Math.trunc(delta / WEEK), 'week']; 95 | } 96 | 97 | if (delta < YEAR) { 98 | return [Math.trunc(delta / MONTH), 'month']; 99 | } 100 | 101 | return [Math.trunc(delta / YEAR), 'year']; 102 | } 103 | -------------------------------------------------------------------------------- /src/templates/pages/SearchPage.tsx: -------------------------------------------------------------------------------- 1 | import { html } from '@intrnl/jsx-to-string'; 2 | 3 | import { get_page_context } from '../context.ts'; 4 | import { get_asset_url, get_tid_segment } from '../utils/url.ts'; 5 | 6 | import Page from '../components/Page.tsx'; 7 | 8 | export interface SearchPageProps {} 9 | 10 | const enum PostFlags { 11 | HAS_EMBED_IMAGE = 1 << 0, 12 | HAS_EMBED_LINK = 1 << 1, 13 | HAS_EMBED_RECORD = 1 << 2, 14 | HAS_EMBED_FEED = 1 << 3, 15 | HAS_EMBED_LIST = 1 << 4, 16 | } 17 | 18 | type PostEntry = [rkey: string, text: string, ts: number, flag: number]; 19 | 20 | const is_nan = Number.isNaN; 21 | 22 | export function SearchPage({}: SearchPageProps) { 23 | const ctx = get_page_context(); 24 | const entries: PostEntry[] = []; 25 | 26 | { 27 | const posts = ctx.records.posts; 28 | 29 | for (const [rkey, post] of posts) { 30 | const text = post.text; 31 | 32 | if (!text.trim()) { 33 | continue; 34 | } 35 | 36 | const ts = new Date(post.createdAt).getTime(); 37 | const is_ts_valid = !is_nan(ts); 38 | 39 | let flag = 0; 40 | 41 | { 42 | const embed = post.embed; 43 | if (embed !== undefined) { 44 | const $type = embed.$type; 45 | 46 | if ($type === 'app.bsky.embed.external') { 47 | flag |= PostFlags.HAS_EMBED_LINK; 48 | } else if ($type === 'app.bsky.embed.images') { 49 | flag |= PostFlags.HAS_EMBED_IMAGE; 50 | } else if ($type === 'app.bsky.embed.record') { 51 | flag |= PostFlags.HAS_EMBED_RECORD; 52 | } else if ($type === 'app.bsky.embed.recordWithMedia') { 53 | const $mediatype = embed.media.$type; 54 | 55 | flag |= PostFlags.HAS_EMBED_RECORD; 56 | 57 | if ($mediatype === 'app.bsky.embed.external') { 58 | flag |= PostFlags.HAS_EMBED_LINK; 59 | } else if ($mediatype === 'app.bsky.embed.images') { 60 | flag |= PostFlags.HAS_EMBED_IMAGE; 61 | } 62 | } 63 | } 64 | } 65 | 66 | entries.push([get_tid_segment(rkey), text, is_ts_valid ? ts : 0, flag]); 67 | } 68 | } 69 | 70 | return ( 71 | 72 |
73 | 76 | 77 |

Loading search, this might take a while.

78 |
79 | 80 | {/* Wrapping it in a 89 |
90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/templates/pages/ThreadPage.tsx: -------------------------------------------------------------------------------- 1 | // page: posts/:rkey.html 2 | 3 | import { repeat, type JSXNode } from '@intrnl/jsx-to-string'; 4 | 5 | import type { AppBskyFeedPost, At } from '@mary/bluesky-client/lexicons'; 6 | 7 | import { get_blob_str, get_page_context, type PageContext } from '../context.ts'; 8 | import { 9 | get_blob_url, 10 | get_bsky_app_url, 11 | get_collection_ns, 12 | get_post_url, 13 | get_record_key, 14 | get_repo_id, 15 | } from '../utils/url.ts'; 16 | 17 | import type { EmbeddedImage, EmbeddedRecord } from '../utils/embed.ts'; 18 | 19 | import FeedPost from '../components/FeedPost.tsx'; 20 | import Page from '../components/Page.tsx'; 21 | import PermalinkPost from '../components/PermalinkPost.tsx'; 22 | import ReplyTree from '../components/ReplyTree.tsx'; 23 | 24 | interface ThreadPageProps { 25 | rkey: string; 26 | post: AppBskyFeedPost.Record; 27 | } 28 | 29 | const MAX_ANCESTORS = 6; 30 | 31 | const enum ExternalReply { 32 | // Top-most post isn't linking to a reply 33 | NO, 34 | // Top-most post is replying to own post, but it no longer exists. 35 | SAME_USER, 36 | // Top-most post is linking to another user. 37 | YES, 38 | } 39 | 40 | export function ThreadPage({ rkey, post }: ThreadPageProps) { 41 | const ctx = get_page_context(); 42 | 43 | let top_post = post; 44 | let top_rkey = rkey; 45 | 46 | let ancestors: [rkey: string, post: AppBskyFeedPost.Record][] = []; 47 | let children: [rkey: string, post: AppBskyFeedPost.Record][] = []; 48 | 49 | let reply_state = ExternalReply.NO; 50 | let root_rkey: string | undefined; 51 | let is_ancestor_overflowing = false; 52 | 53 | { 54 | const graph = ctx.post_graph; 55 | 56 | const entry = graph.get(rkey); 57 | if (entry !== undefined) { 58 | const posts = ctx.records.posts; 59 | 60 | // Collect children replies to this post 61 | { 62 | const descendants = entry.descendants; 63 | 64 | for (let i = 0, ilen = descendants.length; i < ilen; i++) { 65 | const child_rkey = descendants[i]; 66 | const child_post = posts.get(child_rkey); 67 | 68 | if (child_post !== undefined) { 69 | children.push([child_rkey, child_post]); 70 | } 71 | } 72 | } 73 | 74 | // Collect parent replies to this post 75 | { 76 | let parent_rkey: string | null | undefined = entry.ancestor; 77 | let count = 0; 78 | 79 | while (parent_rkey != null && !is_ancestor_overflowing) { 80 | const parent_post = posts.get(parent_rkey); 81 | if (parent_post === undefined) { 82 | break; 83 | } 84 | 85 | top_rkey = parent_rkey; 86 | top_post = parent_post; 87 | is_ancestor_overflowing = ++count >= MAX_ANCESTORS; 88 | 89 | ancestors.unshift([parent_rkey, parent_post]); 90 | 91 | const parent_entry = graph.get(parent_rkey); 92 | parent_rkey = parent_entry?.ancestor; 93 | } 94 | } 95 | } 96 | } 97 | 98 | // Check if the top-most post contains a reply 99 | { 100 | const reply = top_post.reply; 101 | 102 | if (reply !== undefined) { 103 | const our_did = ctx.profile.did; 104 | 105 | { 106 | const parent_uri = reply.parent.uri; 107 | const repo = get_repo_id(parent_uri); 108 | 109 | reply_state = repo === our_did ? ExternalReply.SAME_USER : ExternalReply.YES; 110 | } 111 | 112 | { 113 | const root_uri = reply.root.uri; 114 | const repo = get_repo_id(root_uri) as At.DID; 115 | const rkey = get_record_key(root_uri); 116 | 117 | if (repo === our_did && ctx.records.posts.has(rkey)) { 118 | root_rkey = rkey; 119 | } 120 | } 121 | } 122 | } 123 | 124 | return ( 125 | 126 | {ancestors.length > 0 || reply_state !== ExternalReply.NO ? ( 127 |
128 | 129 | 130 | 131 | 132 | 133 | Show parent replies 134 | 135 | 136 |
137 | {is_ancestor_overflowing || reply_state !== ExternalReply.NO ? ( 138 |
139 |
140 |
141 |
142 |
143 | {is_ancestor_overflowing ? ( 144 | 145 | View parent reply 146 | 147 | ) : ( 148 | <> 149 |

150 | {reply_state === ExternalReply.SAME_USER 151 | ? `This post has been deleted` 152 | : `The post below is a reply to another user`} 153 |

154 | 155 |
156 | {reply_state !== ExternalReply.SAME_USER ? ( 157 | 162 | view in bsky.app 163 | 164 | ) : null} 165 | 166 | {reply_state !== ExternalReply.SAME_USER && root_rkey ? ( 167 | 170 | ) : null} 171 | 172 | {root_rkey ? ( 173 | 174 | view root post 175 | 176 | ) : null} 177 |
178 | 179 | )} 180 |
181 |
182 | ) : null} 183 | 184 | {repeat(ancestors, ([parent_rkey, parent_post]) => ( 185 | 192 | ))} 193 |
194 |
195 | ) : null} 196 | 197 | 198 | 199 |
200 | 201 |
202 | {repeat(children, ([child_rkey, child_post]) => ( 203 | 204 | ))} 205 |
206 |
207 | ); 208 | } 209 | 210 | function get_title(ctx: PageContext, post: AppBskyFeedPost.Record): string { 211 | const author = ctx.profile; 212 | return `${author.displayName || `@${author.handle}`}: "${post.text}"`; 213 | } 214 | 215 | function get_embed_head(ctx: PageContext, post: AppBskyFeedPost.Record): JSXNode { 216 | const nodes: JSXNode = []; 217 | 218 | const embed = post.embed; 219 | const reply = post.reply; 220 | 221 | const profile = ctx.profile; 222 | const title = profile.displayName ? `${profile.displayName} (@${profile.handle})` : profile.handle; 223 | 224 | let header = ''; 225 | let text = post.text; 226 | 227 | if (reply) { 228 | const parent_uri = reply.parent.uri; 229 | const repo = get_repo_id(parent_uri); 230 | 231 | if (repo === profile.did) { 232 | header += `[replying to self] `; 233 | } else { 234 | header += `[replying to ${repo}] `; 235 | } 236 | } 237 | 238 | if (embed) { 239 | const $type = embed.$type; 240 | 241 | let images: EmbeddedImage[] | undefined; 242 | let record: EmbeddedRecord | undefined; 243 | 244 | if ($type === 'app.bsky.embed.images') { 245 | images = embed.images; 246 | } else if ($type === 'app.bsky.embed.record') { 247 | record = embed.record; 248 | } else if ($type === 'app.bsky.embed.recordWithMedia') { 249 | const media = embed.media; 250 | 251 | record = embed.record.record; 252 | 253 | if (media.$type === 'app.bsky.embed.images') { 254 | images = images; 255 | } 256 | } 257 | 258 | if (images !== undefined) { 259 | const img = images[0]; 260 | const url = get_blob_url(get_blob_str(img.image)); 261 | 262 | nodes.push( 263 | <> 264 | 265 | 266 | , 267 | ); 268 | } 269 | 270 | if (record !== undefined) { 271 | const uri = record.uri; 272 | 273 | const repo = get_repo_id(uri); 274 | const ns = get_collection_ns(uri); 275 | 276 | if (ns === 'app.bsky.feed.post') { 277 | if (repo === profile.did) { 278 | header += `[quoting self] `; 279 | } else { 280 | header += `[quoting ${repo}] `; 281 | } 282 | } 283 | } 284 | } 285 | 286 | if (header) { 287 | text = `${header}\n\n${text}`; 288 | } 289 | 290 | nodes.push( 291 | <> 292 | 293 | 294 | , 295 | ); 296 | 297 | return nodes; 298 | } 299 | -------------------------------------------------------------------------------- /src/templates/pages/TimelinePage.tsx: -------------------------------------------------------------------------------- 1 | import { repeat } from '@intrnl/jsx-to-string'; 2 | 3 | import { get_page_context } from '../context.ts'; 4 | import { get_relative_url } from '../utils/url.ts'; 5 | 6 | import { create_pagination } from '../utils/pagination.ts'; 7 | import { create_timeline_slices, type PostTuple } from '../utils/timeline.ts'; 8 | 9 | import FeedPost from '../components/FeedPost.tsx'; 10 | import Page from '../components/Page.tsx'; 11 | 12 | type FilterType = 'posts' | 'with_replies' | 'media'; 13 | 14 | export interface TimelinePageProps { 15 | type: FilterType; 16 | current_page: number; 17 | total_pages: number; 18 | posts: PostTuple[]; 19 | } 20 | 21 | const TYPE_LABELS: Record = { 22 | posts: 'Posts', 23 | with_replies: 'Replies', 24 | media: 'Media', 25 | }; 26 | 27 | export function TimelinePage({ type, current_page, total_pages, posts }: TimelinePageProps) { 28 | const ctx = get_page_context(); 29 | 30 | const label = TYPE_LABELS[type] ?? type; 31 | const slices = create_timeline_slices(posts); 32 | 33 | const pagination = create_pagination(current_page, total_pages); 34 | 35 | return ( 36 | 37 |
38 | 39 | 40 | 41 |
42 | 43 |
44 | {repeat(slices, (slice, idx) => { 45 | return ( 46 | <> 47 | {idx !== 0 ?
: null} 48 | 49 | {repeat(slice.items, (item, idx, arr) => { 50 | return ( 51 | 58 | ); 59 | })} 60 | 61 | ); 62 | })} 63 |
64 | 65 |
66 | {repeat(pagination, (val) => { 67 | if (typeof val === 'number') { 68 | return ( 69 | 77 | {val} 78 | 79 | ); 80 | } 81 | 82 | if (val === 'prev') { 83 | const disabled = current_page <= 1; 84 | 85 | return ( 86 | 94 | 95 | 96 | 97 | 98 | ); 99 | } 100 | 101 | if (val === 'next') { 102 | const disabled = current_page >= total_pages; 103 | 104 | return ( 105 | 113 | 114 | 115 | 116 | 117 | ); 118 | } 119 | 120 | if (val === 'dots_start' || val === 'dots_end') { 121 | return ( 122 |
123 | 124 | 128 | 129 |
130 | ); 131 | } 132 | 133 | return null; 134 | })} 135 |
136 |
137 | ); 138 | } 139 | 140 | interface FilterButtonProps { 141 | type: FilterType; 142 | active: boolean; 143 | } 144 | 145 | function FilterButton({ active, type }: FilterButtonProps) { 146 | return ( 147 | 151 | {TYPE_LABELS[type] ?? type} 152 | 153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /src/templates/pages/WelcomePage.tsx: -------------------------------------------------------------------------------- 1 | import { get_page_context } from '../context.ts'; 2 | import { format_long } from '../intl/number.ts'; 3 | 4 | import Page from '../components/Page.tsx'; 5 | 6 | export interface WelcomePageProps {} 7 | 8 | export function WelcomePage({}: WelcomePageProps) { 9 | const ctx = get_page_context(); 10 | 11 | const posts = ctx.records.posts; 12 | 13 | let root_amount = 0; 14 | let replies_amount = 0; 15 | { 16 | for (const post of posts.values()) { 17 | if (post.reply === undefined) { 18 | root_amount++; 19 | } else { 20 | replies_amount++; 21 | } 22 | } 23 | } 24 | 25 | return ( 26 | 27 |
28 |

Welcome to @{ctx.profile.handle}'s Bluesky archive

29 |

30 | There are {format_long(posts.size)} posts in this archive, containing {format_long(root_amount)}{' '} 31 | roots and {format_long(replies_amount)} replies. Use the links at the top to navigate the archive. 32 |

33 |
34 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/templates/utils/embed.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AppBskyEmbedExternal, 3 | AppBskyEmbedImages, 4 | ComAtprotoRepoStrongRef, 5 | } from '@mary/bluesky-client/lexicons'; 6 | 7 | export type EmbeddedImage = AppBskyEmbedImages.Image; 8 | export type EmbeddedLink = AppBskyEmbedExternal.External; 9 | export type EmbeddedRecord = ComAtprotoRepoStrongRef.Main; 10 | -------------------------------------------------------------------------------- /src/templates/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export function clsx(classes: Array): string { 2 | let str = ''; 3 | let tmp: any; 4 | 5 | for (let i = 0, ilen = classes.length; i < ilen; i++) { 6 | if ((tmp = classes[i])) { 7 | str && (str += ' '); 8 | str += tmp; 9 | } 10 | } 11 | 12 | return str; 13 | } 14 | 15 | export function chunked(arr: T[], size: number): T[][] { 16 | const chunks: T[][] = []; 17 | 18 | for (let i = 0, ilen = arr.length; i < ilen; i += size) { 19 | chunks.push(arr.slice(i, i + size)); 20 | } 21 | 22 | return chunks; 23 | } 24 | -------------------------------------------------------------------------------- /src/templates/utils/pagination.ts: -------------------------------------------------------------------------------- 1 | export const PAGINATION_DOT_START = 'dots_start'; 2 | export const PAGINATION_DOT_END = 'dots_end'; 3 | export const PAGINATION_PREVIOUS = 'prev'; 4 | export const PAGINATION_NEXT = 'next'; 5 | 6 | export function create_pagination( 7 | page: number, 8 | total: number, 9 | boundary_count: number = 1, 10 | sibling_count: number = 1, 11 | ) { 12 | const pages_start = range(1, Math.min(boundary_count, total)); 13 | const pages_end = range(Math.max(total - boundary_count + 1, boundary_count + 1), total); 14 | 15 | const siblings_start = Math.max( 16 | Math.min(page - sibling_count, total - boundary_count - sibling_count * 2 - 1), 17 | boundary_count + 2, 18 | ); 19 | 20 | const siblings_end = Math.min( 21 | Math.max(page + sibling_count, boundary_count + sibling_count * 2 + 2), 22 | pages_end.length > 0 ? pages_end[0] - 2 : total - 1, 23 | ); 24 | 25 | return [ 26 | PAGINATION_PREVIOUS, 27 | ...pages_start, 28 | 29 | ...(siblings_start > boundary_count + 2 30 | ? [PAGINATION_DOT_START] 31 | : boundary_count + 1 < total - boundary_count 32 | ? [boundary_count + 1] 33 | : []), 34 | 35 | // Sibling pages 36 | ...range(siblings_start, siblings_end), 37 | 38 | ...(siblings_end < total - boundary_count - 1 39 | ? [PAGINATION_DOT_END] 40 | : total - boundary_count > boundary_count 41 | ? [total - boundary_count] 42 | : []), 43 | 44 | ...pages_end, 45 | PAGINATION_NEXT, 46 | ]; 47 | } 48 | 49 | function range(start: number, end: number) { 50 | const length = end - start + 1; 51 | return Array.from({ length }, (_, index) => start + index); 52 | } 53 | -------------------------------------------------------------------------------- /src/templates/utils/path.ts: -------------------------------------------------------------------------------- 1 | export function normalize_path(path: string): string { 2 | return path.replace(/\/{2,}/g, '/'); 3 | } 4 | 5 | export function trim_path_trailing(path: string): string { 6 | return path.replace(/\/+$/, ''); 7 | } 8 | 9 | export function join_path(...paths: string[]): string { 10 | return normalize_path(paths.join('/')); 11 | } 12 | 13 | export function split_path(path: string): string[] { 14 | return normalize_path(path).split('/'); 15 | } 16 | 17 | export function is_posix_path_sep(code: number): boolean { 18 | return code === 47; 19 | } 20 | 21 | export function relative_path(from: string, to: string) { 22 | if (from === to) return ''; 23 | 24 | // Trim any leading backslashes 25 | let from_start = 1; 26 | const from_end = from.length; 27 | for (; from_start < from_end; ++from_start) { 28 | if (!is_posix_path_sep(from.charCodeAt(from_start))) { 29 | break; 30 | } 31 | } 32 | const from_len = from_end - from_start; 33 | 34 | // Trim any leading backslashes 35 | let to_start = 1; 36 | const to_end = to.length; 37 | for (; to_start < to_end; ++to_start) { 38 | if (!is_posix_path_sep(to.charCodeAt(to_start))) { 39 | break; 40 | } 41 | } 42 | const to_len = to_end - to_start; 43 | 44 | // Compare paths to find the longest common path from root 45 | const length = from_len < to_len ? from_len : to_len; 46 | let last_common_sep = -1; 47 | let i = 0; 48 | for (; i <= length; ++i) { 49 | if (i === length) { 50 | if (to_len > length) { 51 | if (is_posix_path_sep(to.charCodeAt(to_start + i))) { 52 | // We get here if `from` is the exact base path for `to`. 53 | // For example: from='/foo/bar'; to='/foo/bar/baz' 54 | return to.slice(to_start + i + 1); 55 | } else if (i === 0) { 56 | // We get here if `from` is the root 57 | // For example: from='/'; to='/foo' 58 | return to.slice(to_start + i); 59 | } 60 | } else if (from_len > length) { 61 | if (is_posix_path_sep(from.charCodeAt(from_start + i))) { 62 | // We get here if `to` is the exact base path for `from`. 63 | // For example: from='/foo/bar/baz'; to='/foo/bar' 64 | last_common_sep = i; 65 | } else if (i === 0) { 66 | // We get here if `to` is the root. 67 | // For example: from='/foo'; to='/' 68 | last_common_sep = 0; 69 | } 70 | } 71 | break; 72 | } 73 | 74 | const from_code = from.charCodeAt(from_start + i); 75 | const to_code = to.charCodeAt(to_start + i); 76 | 77 | if (from_code !== to_code) { 78 | break; 79 | } else if (is_posix_path_sep(from_code)) { 80 | last_common_sep = i; 81 | } 82 | } 83 | 84 | let out = ''; 85 | // Generate the relative path based on the path difference between `to` 86 | // and `from` 87 | for (i = from_start + last_common_sep + 1; i <= from_end; ++i) { 88 | if (i === from_end || is_posix_path_sep(from.charCodeAt(i))) { 89 | if (out.length === 0) { 90 | out += '..'; 91 | } else { 92 | out += '/..'; 93 | } 94 | } 95 | } 96 | 97 | // Lastly, append the rest of the destination (`to`) path that comes after 98 | // the common path parts 99 | if (out.length > 0) { 100 | return out + to.slice(to_start + last_common_sep); 101 | } else { 102 | to_start += last_common_sep; 103 | 104 | if (is_posix_path_sep(to.charCodeAt(to_start))) { 105 | ++to_start; 106 | } 107 | 108 | return to.slice(to_start); 109 | } 110 | } 111 | 112 | export function get_dirname(path: string) { 113 | let end = -1; 114 | let matched_non_sep = false; 115 | 116 | for (let i = path.length - 1; i >= 1; --i) { 117 | if (is_posix_path_sep(path.charCodeAt(i))) { 118 | if (matched_non_sep) { 119 | end = i; 120 | break; 121 | } 122 | } else { 123 | matched_non_sep = true; 124 | } 125 | } 126 | 127 | // No matches. Fallback based on provided path: 128 | // 129 | // - leading slashes paths 130 | // "/foo" => "/" 131 | // "///foo" => "/" 132 | // - no slash path 133 | // "foo" => "." 134 | if (end === -1) { 135 | return is_posix_path_sep(path.charCodeAt(0)) ? '/' : '.'; 136 | } 137 | 138 | return trim_path_trailing(path.slice(0, end)); 139 | } 140 | -------------------------------------------------------------------------------- /src/templates/utils/posts.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost, At } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_record_key, get_repo_id } from './url.ts'; 4 | 5 | export interface PostGraphEntry { 6 | ancestor: string | null; 7 | descendants: string[]; 8 | } 9 | 10 | export function create_posts_graph(did: At.DID, posts: Map) { 11 | const graph = new Map(); 12 | 13 | for (const [rkey, post] of posts) { 14 | const parent_uri = post.reply?.parent.uri; 15 | 16 | if (!parent_uri) { 17 | continue; 18 | } 19 | 20 | const parent_repo = get_repo_id(parent_uri); 21 | const parent_rkey = get_record_key(parent_uri); 22 | 23 | if (parent_repo !== did) { 24 | continue; 25 | } 26 | 27 | // Add ourself to the parent entry 28 | { 29 | let parent_entry = graph.get(parent_rkey); 30 | if (parent_entry) { 31 | parent_entry.descendants.push(rkey); 32 | } else { 33 | graph.set(parent_rkey, { ancestor: null, descendants: [rkey] }); 34 | } 35 | } 36 | 37 | // Now mark that down in our entry 38 | { 39 | let our_entry = graph.get(rkey); 40 | if (our_entry) { 41 | our_entry.ancestor = parent_rkey; 42 | } else { 43 | graph.set(rkey, { ancestor: parent_rkey, descendants: [] }); 44 | } 45 | } 46 | } 47 | 48 | return graph; 49 | } 50 | -------------------------------------------------------------------------------- /src/templates/utils/richtext/segmentize.ts: -------------------------------------------------------------------------------- 1 | import type { Facet, LinkFeature, MentionFeature, TagFeature } from './types.ts'; 2 | import { create_utf_string, get_utf8_length, slice_utf8 } from './unicode.ts'; 3 | 4 | export interface RichTextSegment { 5 | text: string; 6 | link?: LinkFeature; 7 | mention?: MentionFeature; 8 | tag?: TagFeature; 9 | } 10 | 11 | function create_segment(text: string, facet?: Facet): RichTextSegment { 12 | let link: LinkFeature | undefined; 13 | let mention: MentionFeature | undefined; 14 | let tag: TagFeature | undefined; 15 | 16 | if (facet) { 17 | const features = facet.features; 18 | 19 | for (let idx = 0, len = features.length; idx < len; idx++) { 20 | const feature = features[idx]; 21 | const type = feature.$type; 22 | 23 | if (type === 'app.bsky.richtext.facet#link') { 24 | link = feature; 25 | } else if (type === 'app.bsky.richtext.facet#mention') { 26 | mention = feature; 27 | } else if (type === 'app.bsky.richtext.facet#tag') { 28 | tag = feature; 29 | } 30 | } 31 | } 32 | 33 | return { text, link, mention, tag }; 34 | } 35 | 36 | export function segment_richtext(text: string, facets: Facet[] | undefined) { 37 | if (!facets || facets.length < 1) { 38 | return [create_segment(text)]; 39 | } 40 | 41 | const ustr = create_utf_string(text); 42 | 43 | const segments: RichTextSegment[] = []; 44 | const length = get_utf8_length(ustr); 45 | 46 | const facets_length = facets.length; 47 | 48 | let text_cursor = 0; 49 | let facet_cursor = 0; 50 | 51 | do { 52 | const facet = facets[facet_cursor]; 53 | const { byteStart, byteEnd } = facet.index; 54 | 55 | if (text_cursor < byteStart) { 56 | segments.push(create_segment(slice_utf8(ustr, text_cursor, byteStart))); 57 | } else if (text_cursor > byteStart) { 58 | facet_cursor++; 59 | continue; 60 | } 61 | 62 | if (byteStart < byteEnd) { 63 | const subtext = slice_utf8(ustr, byteStart, byteEnd); 64 | 65 | if (!subtext.trim()) { 66 | // dont empty string entities 67 | segments.push(create_segment(subtext)); 68 | } else { 69 | segments.push(create_segment(subtext, facet)); 70 | } 71 | } 72 | 73 | text_cursor = byteEnd; 74 | facet_cursor++; 75 | } while (facet_cursor < facets_length); 76 | 77 | if (text_cursor < length) { 78 | segments.push(create_segment(slice_utf8(ustr, text_cursor, length))); 79 | } 80 | 81 | return segments; 82 | } 83 | -------------------------------------------------------------------------------- /src/templates/utils/richtext/types.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyRichtextFacet, Brand } from '@mary/bluesky-client/lexicons'; 2 | 3 | export type Facet = AppBskyRichtextFacet.Main; 4 | export type LinkFeature = Brand.Union; 5 | export type MentionFeature = Brand.Union; 6 | export type TagFeature = Brand.Union; 7 | -------------------------------------------------------------------------------- /src/templates/utils/richtext/unicode.ts: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | const decoder = new TextDecoder(); 3 | 4 | export interface UtfString { 5 | u16: string; 6 | u8: Uint8Array; 7 | } 8 | 9 | export const create_utf_string = (utf16: string): UtfString => { 10 | return { 11 | u16: utf16, 12 | u8: encoder.encode(utf16), 13 | }; 14 | }; 15 | 16 | export const get_utf8_length = (utf: UtfString) => { 17 | return utf.u8.byteLength; 18 | }; 19 | 20 | export const slice_utf8 = (utf: UtfString, start?: number, end?: number) => { 21 | return decoder.decode(utf.u8.slice(start, end)); 22 | }; 23 | -------------------------------------------------------------------------------- /src/templates/utils/timeline.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost, At } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_page_context } from '../context.ts'; 4 | 5 | export type PostTuple = [rkey: string, post: AppBskyFeedPost.Record]; 6 | 7 | export interface TimelineItem { 8 | uri: At.Uri; 9 | rkey: string; 10 | post: AppBskyFeedPost.Record; 11 | } 12 | 13 | export interface TimelineSlice { 14 | items: TimelineItem[]; 15 | } 16 | 17 | function is_next_in_slice(slice: TimelineSlice, item: TimelineItem) { 18 | const items = slice.items; 19 | const last = items[items.length - 1]; 20 | 21 | const reply = item.post.reply; 22 | return reply !== undefined && last.uri === reply.parent.uri; 23 | } 24 | function is_first_in_slice(slice: TimelineSlice, item: TimelineItem) { 25 | const items = slice.items; 26 | const first = items[0]; 27 | 28 | const reply = first.post.reply; 29 | return reply !== undefined && reply.parent.uri === item.uri; 30 | } 31 | 32 | export function create_timeline_slices(arr: PostTuple[]) { 33 | const ctx = get_page_context(); 34 | const did = ctx.profile.did; 35 | 36 | const slices: TimelineSlice[] = []; 37 | let jlen = 0; 38 | 39 | loop: for (let i = arr.length - 1; i >= 0; i--) { 40 | const [rkey, post] = arr[i]; 41 | 42 | const item: TimelineItem = { 43 | uri: `at://${did}/app.bsky.feed.post/${rkey}`, 44 | rkey: rkey, 45 | post: post, 46 | }; 47 | 48 | // if we find a matching slice and it's currently not in front, then bump 49 | // it to the front. this is so that new reply don't get buried away because 50 | // there's multiple posts separating it and the parent post. 51 | for (let j = 0; j < jlen; j++) { 52 | const slice = slices[j]; 53 | 54 | if (is_first_in_slice(slice, item)) { 55 | slice.items.unshift(item); 56 | 57 | if (j !== 0) { 58 | slices.splice(j, 1); 59 | slices.unshift(slice); 60 | } 61 | 62 | continue loop; 63 | } else if (is_next_in_slice(slice, item)) { 64 | slice.items.push(item); 65 | 66 | if (j !== 0) { 67 | slices.splice(j, 1); 68 | slices.unshift(slice); 69 | } 70 | 71 | continue loop; 72 | } 73 | } 74 | 75 | slices.unshift({ items: [item] }); 76 | jlen++; 77 | } 78 | 79 | return slices; 80 | } 81 | -------------------------------------------------------------------------------- /src/templates/utils/url.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@mary/bluesky-client/lexicons'; 2 | 3 | import { get_page_context } from '../context.ts'; 4 | import { get_dirname, join_path, relative_path } from './path.ts'; 5 | 6 | export function get_record_key(uri: At.Uri) { 7 | const idx = uri.lastIndexOf('/'); 8 | return uri.slice(idx + 1); 9 | } 10 | 11 | export function get_collection_ns(uri: At.Uri) { 12 | const first = uri.indexOf('/', 5); 13 | const second = uri.indexOf('/', first + 1); 14 | 15 | return uri.slice(first + 1, second); 16 | } 17 | 18 | export function get_repo_id(uri: At.Uri) { 19 | const idx = uri.indexOf('/', 5); 20 | return uri.slice(5, idx); 21 | } 22 | 23 | export function get_bsky_app_url(uri: At.Uri) { 24 | const repo = get_repo_id(uri); 25 | const ns = get_collection_ns(uri); 26 | const rkey = get_record_key(uri); 27 | 28 | if (ns === 'app.bsky.feed.post') { 29 | return `https://bsky.app/profile/${repo}/post/${rkey}`; 30 | } 31 | 32 | if (ns === 'app.bsky.feed.generator') { 33 | return `https://bsky.app/profile/${repo}/feed/${rkey}`; 34 | } 35 | 36 | if (ns === 'app.bsky.graph.list') { 37 | return `https://bsky.app/profile/${repo}/lists/${rkey}`; 38 | } 39 | 40 | if (ns === 'app.bsky.actor.profile') { 41 | return `https://bsky.app/profile/${repo}`; 42 | } 43 | 44 | throw new Error(`unsupported uri: ${uri}`); 45 | } 46 | 47 | export function get_tid_segment(rkey: string) { 48 | // Use whatever's left after removing the last 10 characters as the bucket. 49 | const split = -10; 50 | 51 | return `${rkey.slice(0, split)}/${rkey.slice(split)}`; 52 | } 53 | 54 | export function get_cid_segment(cid: string) { 55 | // Use the first 8 characters as the bucket 56 | // Bluesky CIDs always starts with bafkrei (7 chars) 57 | const split = 8; 58 | 59 | return `${cid.slice(0, split)}/${cid.slice(split)}`; 60 | } 61 | 62 | export function get_relative_url(url: string) { 63 | const ctx = get_page_context(); 64 | 65 | return relative_path(get_dirname(ctx.path), url); 66 | } 67 | 68 | export function get_asset_url(asset: string) { 69 | const ctx = get_page_context(); 70 | 71 | return get_relative_url(join_path(ctx.asset_dir, asset)); 72 | } 73 | 74 | export function get_blob_url(cid: string) { 75 | const ctx = get_page_context(); 76 | const segment = get_cid_segment(cid); 77 | 78 | return get_relative_url(join_path(ctx.blob_dir, `${segment}`)); 79 | } 80 | 81 | export function get_post_url(rkey: string) { 82 | const ctx = get_page_context(); 83 | const segment = get_tid_segment(rkey); 84 | 85 | return get_relative_url(join_path(ctx.posts_dir, `${segment}.html`)); 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/async-pool.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from './queue.ts'; 2 | 3 | type Result = [promise: Promise, value: any]; 4 | 5 | export function create_async_pool(concurrency: number, iterator: (value: T) => Promise) { 6 | const queue = new Queue(); 7 | const executing = new Set>(); 8 | 9 | async function consume() { 10 | const [promise, value] = await Promise.race(executing); 11 | 12 | executing.delete(promise); 13 | return value; 14 | } 15 | 16 | return { 17 | add(item: T) { 18 | queue.push(item); 19 | }, 20 | addMany(items: T[]) { 21 | for (let i = 0, ilen = items.length; i < ilen; i++) { 22 | const item = items[i]; 23 | queue.push(item); 24 | } 25 | }, 26 | async *flush() { 27 | let item: T | undefined; 28 | 29 | while ((item = queue.shift()) !== undefined) { 30 | const promise = iterator(item).then((value): Result => [promise, value]); 31 | 32 | executing.add(promise); 33 | if (executing.size >= concurrency) { 34 | yield (await consume()) as R; 35 | } 36 | } 37 | 38 | while (executing.size > 0) { 39 | yield (await consume()) as R; 40 | } 41 | }, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/controller.ts: -------------------------------------------------------------------------------- 1 | export function target(base: HTMLElement, name: string) { 2 | return { get: () => base.querySelector(`[data-target~="${base.localName}.${name}"]`) }; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/format-bytes.ts: -------------------------------------------------------------------------------- 1 | const BYTE = 1; 2 | const KILOBYTE = BYTE * 1000; 3 | const MEGABYTE = KILOBYTE * 1000; 4 | const GIGABYTE = MEGABYTE * 1000; 5 | 6 | export function format_bytes(size: number) { 7 | let num = size; 8 | let fractions = 0; 9 | let unit: string; 10 | 11 | if (size < KILOBYTE) { 12 | unit = 'byte'; 13 | } else if (size < MEGABYTE) { 14 | num /= KILOBYTE; 15 | unit = 'kilobyte'; 16 | } else if (size < GIGABYTE) { 17 | num /= MEGABYTE; 18 | unit = 'megabyte'; 19 | } else { 20 | num /= GIGABYTE; 21 | unit = 'gigabyte'; 22 | } 23 | 24 | if (num > 100) { 25 | fractions = 0; 26 | } else if (num > 10) { 27 | fractions = 1; 28 | } else if (num > 1) { 29 | fractions = 2; 30 | } 31 | 32 | return num.toLocaleString('en-US', { 33 | style: 'unit', 34 | unit: unit, 35 | unitDisplay: 'short', 36 | maximumFractionDigits: fractions, 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/hashes.ts: -------------------------------------------------------------------------------- 1 | import { coerce } from 'multiformats/bytes'; 2 | import { from } from 'multiformats/hashes/hasher'; 3 | 4 | export const sha256 = (input: Uint8Array) => { 5 | return crypto.subtle.digest('sha-256', input); 6 | }; 7 | 8 | export const mf_sha256 = from({ 9 | name: 'sha2-256', 10 | code: 0x12, 11 | encode: async (input) => { 12 | const digest = await sha256(input); 13 | return coerce(digest); 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/utils/logger.tsx: -------------------------------------------------------------------------------- 1 | // @ts-expect-error 2 | Symbol.dispose ??= Symbol.for('Symbol.dispose'); 3 | 4 | const time_format = new Intl.DateTimeFormat('en-US', { timeStyle: 'short', hour12: false }); 5 | 6 | let uid = 0; 7 | 8 | export class Logger { 9 | public container: HTMLElement; 10 | public signal: AbortSignal | undefined; 11 | 12 | private _ephemeral: HTMLElement; 13 | private _persistent: HTMLElement; 14 | 15 | private _destroy = this.destroy.bind(this); 16 | 17 | constructor(parent: HTMLElement, signal?: AbortSignal) { 18 | const id = 'log_container_' + uid++; 19 | 20 | const html = ( 21 |
22 |
    23 |
      24 |
      25 | ); 26 | 27 | parent.insertAdjacentHTML('beforeend', html.value); 28 | 29 | this.container = parent.ownerDocument.getElementById(id)!; 30 | this._persistent = this.container.firstChild! as HTMLElement; 31 | this._ephemeral = this._persistent.nextSibling! as HTMLElement; 32 | 33 | if (signal) { 34 | signal.throwIfAborted(); 35 | signal.addEventListener('abort', this._destroy); 36 | 37 | this.signal = signal; 38 | } 39 | } 40 | 41 | log(message: string) { 42 | const now = Date.now(); 43 | 44 | const html = ( 45 |
    • 46 | {time_format.format(now)} 47 | {message} 48 |
    • 49 | ); 50 | 51 | this._ephemeral.insertAdjacentHTML('afterbegin', html.value); 52 | } 53 | 54 | warn(message: string) { 55 | const now = Date.now(); 56 | 57 | const html = ( 58 |
    • 59 | {time_format.format(now)} 60 | {message} 61 |
    • 62 | ); 63 | 64 | this._ephemeral.insertAdjacentHTML('afterbegin', html.value); 65 | } 66 | 67 | error(message: string) { 68 | const now = Date.now(); 69 | 70 | const html = ( 71 |
    • 72 | {time_format.format(now)} 73 | {message} 74 |
    • 75 | ); 76 | 77 | this._ephemeral.insertAdjacentHTML('afterbegin', html.value); 78 | } 79 | 80 | progress(message: string = '', interval: number | null = 250) { 81 | const id = 'log_progress_' + uid++; 82 | 83 | const html = ( 84 |
    • 85 | ----- 86 | 87 |
    • 88 | ); 89 | 90 | this._persistent.insertAdjacentHTML('afterbegin', html.value); 91 | 92 | const container = this.container.ownerDocument.getElementById(id)! as HTMLElement; 93 | const text = container.firstChild!.nextSibling!.firstChild! as Text; 94 | 95 | text.data = message; 96 | return new ProgressLogger(container, text, interval); 97 | } 98 | 99 | destroy() { 100 | this.signal?.removeEventListener('abort', this._destroy); 101 | this.container.remove(); 102 | } 103 | } 104 | 105 | class ProgressLogger { 106 | private _container: HTMLElement; 107 | private _text: Text; 108 | private _interval: number | null; 109 | 110 | public ratelimited = false; 111 | 112 | constructor(container: HTMLElement, text: Text, interval: number | null) { 113 | this._container = container; 114 | this._text = text; 115 | this._interval = interval; 116 | } 117 | 118 | update(message: string) { 119 | if (this._interval === null) { 120 | this._text.data = message; 121 | } else if (!this.ratelimited) { 122 | this.ratelimited = true; 123 | this._text.data = message; 124 | 125 | setTimeout(() => { 126 | this.ratelimited = false; 127 | }, this._interval); 128 | } 129 | } 130 | 131 | destroy() { 132 | this._container.remove(); 133 | } 134 | 135 | [Symbol.dispose]() { 136 | return this.destroy(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export async function* iterate_stream(stream: ReadableStream) { 2 | // Get a lock on the stream 3 | const reader = stream.getReader(); 4 | 5 | try { 6 | while (true) { 7 | const { done, value } = await reader.read(); 8 | 9 | if (done) { 10 | return; 11 | } 12 | 13 | yield value; 14 | } 15 | } finally { 16 | reader.releaseLock(); 17 | } 18 | } 19 | 20 | export function assert(condition: boolean, message: string): asserts condition { 21 | if (!condition) { 22 | throw new Error(message); 23 | } 24 | } 25 | 26 | const EMPTY_BUFFER = new Uint8Array(0); 27 | 28 | export interface IterableReader { 29 | read(p: Uint8Array): Promise; 30 | seek(n: number): Promise; 31 | } 32 | 33 | export function create_iterable_reader(iterable: AsyncIterable): IterableReader { 34 | const iterator = iterable[Symbol.asyncIterator](); 35 | 36 | let pages: Uint8Array[] = []; 37 | let buffer = EMPTY_BUFFER; 38 | 39 | let ptr = 0; 40 | let size = 0; 41 | let read = 0; 42 | 43 | return { 44 | async read(p: Uint8Array): Promise { 45 | while (size < p.byteLength) { 46 | let result = await iterator.next(); 47 | 48 | if (result.done) { 49 | break; 50 | } 51 | 52 | let chunk = result.value; 53 | let length = chunk.byteLength; 54 | 55 | size += length; 56 | read += length; 57 | 58 | pages.push(chunk); 59 | } 60 | 61 | if (size < 1) { 62 | pages = []; 63 | buffer = new Uint8Array(0); 64 | return null; 65 | } 66 | 67 | let unwritten = p.byteLength; 68 | 69 | while (unwritten > 0) { 70 | let remaining = buffer.byteLength - ptr; 71 | let length = Math.min(unwritten, remaining); 72 | 73 | p.set(buffer.subarray(ptr, ptr + length), p.byteLength - unwritten); 74 | 75 | ptr += length; 76 | unwritten -= length; 77 | size -= length; 78 | 79 | if (ptr >= buffer.length) { 80 | if (pages.length < 1) { 81 | break; 82 | } 83 | 84 | buffer = pages.shift()!; 85 | ptr = 0; 86 | } 87 | } 88 | 89 | return p.byteLength - unwritten; 90 | }, 91 | async seek(n: number): Promise { 92 | while (size < n) { 93 | let result = await iterator.next(); 94 | 95 | if (result.done) { 96 | break; 97 | } 98 | 99 | let chunk = result.value; 100 | let length = chunk.byteLength; 101 | 102 | size += length; 103 | read += length; 104 | 105 | pages.push(chunk); 106 | } 107 | 108 | ptr += n; 109 | size -= n; 110 | read += n; 111 | 112 | while (ptr >= buffer.byteLength && pages.length > 0) { 113 | ptr -= buffer.byteLength; 114 | buffer = pages.shift()!; 115 | } 116 | 117 | return read; 118 | }, 119 | }; 120 | } 121 | -------------------------------------------------------------------------------- /src/utils/queue.ts: -------------------------------------------------------------------------------- 1 | interface QueueNode { 2 | v: T; 3 | n?: QueueNode; 4 | } 5 | 6 | export class Queue { 7 | private h?: QueueNode; 8 | private t?: QueueNode; 9 | 10 | public size = 0; 11 | 12 | push(value: T) { 13 | const node: QueueNode = { v: value, n: undefined }; 14 | 15 | if (this.h) { 16 | this.t!.n = node; 17 | this.t = node; 18 | } else { 19 | this.h = node; 20 | this.t = node; 21 | } 22 | 23 | this.size++; 24 | } 25 | 26 | shift(): T | undefined { 27 | const curr = this.h; 28 | 29 | if (!curr) { 30 | return; 31 | } 32 | 33 | this.h = curr.n; 34 | this.size--; 35 | 36 | return curr.v; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/tar.ts: -------------------------------------------------------------------------------- 1 | import type { IterableReader } from './misc.ts'; 2 | 3 | export interface TarFileAttributes { 4 | /** @default 0o664 */ 5 | mode?: number; 6 | /** @default 1000 */ 7 | uid?: number; 8 | /** @default 1000 */ 9 | gid?: number; 10 | /** @default Date.now() */ 11 | mtime?: number; 12 | /** @default "" */ 13 | user?: string; 14 | /** @default "" */ 15 | group?: string; 16 | } 17 | 18 | export interface TarFileEntry { 19 | filename: string; 20 | data: string | Uint8Array | ArrayBuffer; 21 | attrs?: TarFileAttributes; 22 | } 23 | 24 | const encoder = new TextEncoder(); 25 | const decoder = new TextDecoder(); 26 | 27 | const RECORD_SIZE = 512; 28 | /** Accounts for the 8 spaces in the checksum field */ 29 | const INITIAL_CHKSUM = 8 * 32; 30 | 31 | const DEFAULT_ATTRS: TarFileAttributes = {}; 32 | 33 | const FILE_TYPES: Record = { 34 | 0: 'file', 35 | 1: 'link', 36 | 2: 'symlink', 37 | 3: 'character_device', 38 | 4: 'block_device', 39 | 5: 'directory', 40 | 6: 'fifo', 41 | 7: 'contiguous_file', 42 | }; 43 | 44 | export function write_tar_entry(entry: TarFileEntry): ArrayBuffer { 45 | const { filename, data, attrs = DEFAULT_ATTRS } = entry; 46 | 47 | let name = filename; 48 | let prefix = ''; 49 | 50 | if (name.length > 100) { 51 | let i = 0; 52 | while (name.length > 100) { 53 | i = filename.indexOf('/', i); 54 | 55 | if (i === -1) { 56 | break; 57 | } 58 | 59 | prefix = filename.slice(0, i); 60 | name = filename.slice(i + 1); 61 | } 62 | 63 | if (name.length > 100 || prefix.length > 155) { 64 | const total_length = (prefix.length && prefix.length + 1) + name.length; 65 | throw new Error(`filename is too long (${total_length})`); 66 | } 67 | } 68 | 69 | const data_buf = normalize_data(data); 70 | const data_size = data_buf.byteLength; 71 | 72 | const padding_size = RECORD_SIZE - (data_size % RECORD_SIZE || RECORD_SIZE); 73 | 74 | const buf = new ArrayBuffer(512 + data_size + padding_size); 75 | 76 | // File name 77 | write_str(buf, name, 0, 100); 78 | 79 | // File mode 80 | write_str(buf, pad(attrs.mode ?? 0o664, 7), 100, 8); 81 | 82 | // UID 83 | write_str(buf, pad(attrs.uid ?? 1000, 7), 108, 8); 84 | 85 | // GID 86 | write_str(buf, pad(attrs.gid ?? 1000, 7), 116, 8); 87 | 88 | // File size 89 | write_str(buf, pad(data_size, 11), 124, 12); 90 | 91 | // Modified time 92 | write_str(buf, pad(attrs.mtime ?? Date.now(), 11), 136, 12); 93 | 94 | // File type 95 | write_str(buf, '0', 156, 12); 96 | 97 | // Ustar 98 | write_str(buf, 'ustar00', 257, 8); 99 | 100 | // User ownership 101 | write_str(buf, attrs.user ?? '', 265, 32); 102 | 103 | // User group 104 | write_str(buf, attrs.group ?? '', 297, 32); 105 | 106 | // File prefix 107 | write_str(buf, prefix, 345, 155); 108 | 109 | // Checksum 110 | { 111 | const header = new Uint8Array(buf, 0, 512); 112 | const chksum = get_checksum(header); 113 | 114 | write_str(buf, pad(chksum, 8), 148, 8); 115 | } 116 | 117 | // Actual data 118 | { 119 | const dest = new Uint8Array(buf, 512, data_size); 120 | dest.set(data_buf, 0); 121 | } 122 | 123 | return buf; 124 | } 125 | 126 | export async function* untar(reader: IterableReader): AsyncGenerator { 127 | const header = new Uint8Array(512); 128 | 129 | let entry: TarEntry | undefined; 130 | 131 | while (true) { 132 | if (entry) { 133 | await entry.discard(); 134 | } 135 | 136 | const res = await reader.read(header); 137 | 138 | if (res === null) { 139 | break; 140 | } 141 | 142 | // validate checksum 143 | { 144 | const expected = read_octal(header, 148, 8); 145 | const actual = get_checksum(header); 146 | if (expected !== actual) { 147 | if (actual === INITIAL_CHKSUM) { 148 | break; 149 | } 150 | 151 | throw new Error(`invalid checksum, expected ${expected} got ${actual}`); 152 | } 153 | } 154 | 155 | // validate magic 156 | { 157 | const magic = read_str(header, 257, 8); 158 | if (!magic.startsWith('ustar')) { 159 | throw new Error(`unsupported archive format: ${magic}`); 160 | } 161 | } 162 | 163 | entry = new TarEntry(header, reader); 164 | yield entry; 165 | } 166 | } 167 | 168 | class TarEntry { 169 | private reader: IterableReader; 170 | 171 | private bytes_read: number = 0; 172 | 173 | readonly name: string; 174 | readonly mode: number; 175 | readonly uid: number; 176 | readonly gid: number; 177 | readonly size: number; 178 | readonly mtime: number; 179 | readonly type: string; 180 | readonly link_name: string; 181 | readonly owner: string; 182 | readonly group: string; 183 | readonly entry_size: number; 184 | 185 | constructor(header: Uint8Array, reader: IterableReader) { 186 | const name = read_str(header, 0, 100); 187 | const mode = read_octal(header, 100, 8); 188 | const uid = read_octal(header, 108, 8); 189 | const gid = read_octal(header, 116, 8); 190 | const size = read_octal(header, 124, 12); 191 | const mtime = read_octal(header, 136, 12); 192 | const type = read_octal(header, 156, 1); 193 | const link_name = read_str(header, 157, 100); 194 | const owner = read_str(header, 265, 32); 195 | const group = read_str(header, 297, 32); 196 | const prefix = read_str(header, 345, 155); 197 | 198 | this.name = prefix.length > 0 ? prefix + '/' + name : name; 199 | this.mode = mode; 200 | this.uid = uid; 201 | this.gid = gid; 202 | this.size = size; 203 | this.mtime = mtime; 204 | this.type = FILE_TYPES[type] ?? '' + type; 205 | this.link_name = link_name; 206 | this.owner = owner; 207 | this.group = group; 208 | this.entry_size = Math.ceil(this.size / RECORD_SIZE) * RECORD_SIZE; 209 | 210 | this.reader = reader; 211 | } 212 | 213 | async read(p: Uint8Array): Promise { 214 | let remaining = this.size - this.bytes_read; 215 | 216 | if (remaining <= 0) { 217 | return null; 218 | } 219 | 220 | if (p.byteLength <= remaining) { 221 | this.bytes_read += p.byteLength; 222 | return await this.reader.read(p); 223 | } 224 | 225 | // User exceeded the remaining size of this entry, we can't fulfill that 226 | // directly because it means reading partially into the next entry 227 | this.bytes_read += remaining; 228 | 229 | let block = new Uint8Array(remaining); 230 | let n = await this.reader.read(block); 231 | 232 | p.set(block, 0); 233 | return n; 234 | } 235 | 236 | async discard() { 237 | let remaining = this.entry_size - this.bytes_read; 238 | 239 | if (remaining <= 0) { 240 | return null; 241 | } 242 | 243 | await this.reader.seek(remaining); 244 | } 245 | } 246 | 247 | function get_checksum(buf: Uint8Array) { 248 | let checksum = INITIAL_CHKSUM; 249 | 250 | for (let i = 0; i < 512; i++) { 251 | // Ignore own checksum field 252 | if (i >= 148 && i < 156) { 253 | continue; 254 | } 255 | 256 | checksum += buf[i]; 257 | } 258 | 259 | return checksum; 260 | } 261 | 262 | function write_str(buf: ArrayBuffer, str: string, offset: number, size: number) { 263 | const view = new Uint8Array(buf, offset, size); 264 | encoder.encodeInto(str, view); 265 | } 266 | 267 | function read_str(arr: Uint8Array, offset: number, size: number): string { 268 | let input = arr.subarray(offset, offset + size); 269 | 270 | for (let idx = 0, len = input.length; idx < len; idx++) { 271 | let code = input[idx]; 272 | 273 | if (code === 0) { 274 | input = input.subarray(0, idx); 275 | break; 276 | } 277 | } 278 | 279 | return decoder.decode(input); 280 | } 281 | 282 | function read_octal(arr: Uint8Array, offset: number, size: number): number { 283 | const res = read_str(arr, offset, size); 284 | return res ? parseInt(res, 8) : 0; 285 | } 286 | 287 | function pad(input: number, length: number) { 288 | return input.toString(8).padStart(length, '0'); 289 | } 290 | 291 | function normalize_data(data: string | ArrayBuffer | Uint8Array): Uint8Array { 292 | if (typeof data === 'string') { 293 | return encoder.encode(data); 294 | } 295 | 296 | if (data instanceof ArrayBuffer) { 297 | return new Uint8Array(data); 298 | } 299 | 300 | return data; 301 | } 302 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src_templates/scripts/dependencies/hyperapp.js: -------------------------------------------------------------------------------- 1 | // Fork of hyperapp which removes most of its state management-related 2 | // functionalities, we don't really need that here. 3 | 4 | var SSR_NODE = 1; 5 | var TEXT_NODE = 3; 6 | var EMPTY_OBJ = {}; 7 | var EMPTY_ARR = []; 8 | var SVG_NS = 'http://www.w3.org/2000/svg'; 9 | 10 | var map = EMPTY_ARR.map; 11 | var isArray = Array.isArray; 12 | 13 | var getKey = (vdom) => (vdom == null ? vdom : vdom.key); 14 | 15 | var patchProperty = (node, key, oldValue, newValue, isSvg) => { 16 | if (key === 'style') { 17 | for (var k in { ...oldValue, ...newValue }) { 18 | oldValue = newValue == null || newValue[k] == null ? '' : newValue[k]; 19 | if (k[0] === '-') { 20 | node[key].setProperty(k, oldValue); 21 | } else { 22 | node[key][k] = oldValue; 23 | } 24 | } 25 | } else if (key[0] === 'o' && key[1] === 'n') { 26 | key = key.slice(2); 27 | 28 | if (typeof oldValue === 'function') { 29 | node.removeEventListener(key, oldValue); 30 | } 31 | 32 | if (typeof newValue === 'function') { 33 | node.addEventListener(key, newValue); 34 | } 35 | } else if (!isSvg && key !== 'list' && key !== 'form' && key in node) { 36 | node[key] = newValue == null ? '' : newValue; 37 | } else if (newValue == null || newValue === false) { 38 | node.removeAttribute(key); 39 | } else { 40 | node.setAttribute(key, newValue); 41 | } 42 | }; 43 | 44 | var createNode = (vdom, isSvg) => { 45 | var props = vdom.props; 46 | var node = 47 | vdom.type === TEXT_NODE 48 | ? document.createTextNode(vdom.tag) 49 | : (isSvg = isSvg || vdom.tag === 'svg') 50 | ? document.createElementNS(SVG_NS, vdom.tag, props.is && props) 51 | : document.createElement(vdom.tag, props.is && props); 52 | 53 | for (var k in props) { 54 | patchProperty(node, k, null, props[k], isSvg); 55 | } 56 | 57 | for (var i = 0; i < vdom.children.length; i++) { 58 | node.appendChild(createNode((vdom.children[i] = maybeVNode(vdom.children[i])), isSvg)); 59 | } 60 | 61 | return (vdom.node = node); 62 | }; 63 | 64 | var patch = (parent, node, oldVNode, newVNode, isSvg) => { 65 | if (oldVNode === newVNode) { 66 | } else if (oldVNode != null && oldVNode.type === TEXT_NODE && newVNode.type === TEXT_NODE) { 67 | if (oldVNode.tag !== newVNode.tag) node.nodeValue = newVNode.tag; 68 | } else if (oldVNode == null || oldVNode.tag !== newVNode.tag) { 69 | node = parent.insertBefore(createNode((newVNode = maybeVNode(newVNode)), isSvg), node); 70 | if (oldVNode != null) { 71 | parent.removeChild(oldVNode.node); 72 | } 73 | } else { 74 | var tmpVKid; 75 | var oldVKid; 76 | 77 | var oldKey; 78 | var newKey; 79 | 80 | var oldProps = oldVNode.props; 81 | var newProps = newVNode.props; 82 | 83 | var oldVKids = oldVNode.children; 84 | var newVKids = newVNode.children; 85 | 86 | var oldHead = 0; 87 | var newHead = 0; 88 | var oldTail = oldVKids.length - 1; 89 | var newTail = newVKids.length - 1; 90 | 91 | isSvg = isSvg || newVNode.tag === 'svg'; 92 | 93 | for (var i in { ...oldProps, ...newProps }) { 94 | if ((i === 'value' || i === 'selected' || i === 'checked' ? node[i] : oldProps[i]) !== newProps[i]) { 95 | patchProperty(node, i, oldProps[i], newProps[i], isSvg); 96 | } 97 | } 98 | 99 | while (newHead <= newTail && oldHead <= oldTail) { 100 | if ((oldKey = getKey(oldVKids[oldHead])) == null || oldKey !== getKey(newVKids[newHead])) { 101 | break; 102 | } 103 | 104 | patch( 105 | node, 106 | oldVKids[oldHead].node, 107 | oldVKids[oldHead], 108 | (newVKids[newHead] = maybeVNode(newVKids[newHead++], oldVKids[oldHead++])), 109 | isSvg, 110 | ); 111 | } 112 | 113 | while (newHead <= newTail && oldHead <= oldTail) { 114 | if ((oldKey = getKey(oldVKids[oldTail])) == null || oldKey !== getKey(newVKids[newTail])) { 115 | break; 116 | } 117 | 118 | patch( 119 | node, 120 | oldVKids[oldTail].node, 121 | oldVKids[oldTail], 122 | (newVKids[newTail] = maybeVNode(newVKids[newTail--], oldVKids[oldTail--])), 123 | isSvg, 124 | ); 125 | } 126 | 127 | if (oldHead > oldTail) { 128 | while (newHead <= newTail) { 129 | node.insertBefore( 130 | createNode((newVKids[newHead] = maybeVNode(newVKids[newHead++])), isSvg), 131 | (oldVKid = oldVKids[oldHead]) && oldVKid.node, 132 | ); 133 | } 134 | } else if (newHead > newTail) { 135 | while (oldHead <= oldTail) { 136 | node.removeChild(oldVKids[oldHead++].node); 137 | } 138 | } else { 139 | for (var keyed = {}, newKeyed = {}, i = oldHead; i <= oldTail; i++) { 140 | if ((oldKey = oldVKids[i].key) != null) { 141 | keyed[oldKey] = oldVKids[i]; 142 | } 143 | } 144 | 145 | while (newHead <= newTail) { 146 | oldKey = getKey((oldVKid = oldVKids[oldHead])); 147 | newKey = getKey((newVKids[newHead] = maybeVNode(newVKids[newHead], oldVKid))); 148 | 149 | if (newKeyed[oldKey] || (newKey != null && newKey === getKey(oldVKids[oldHead + 1]))) { 150 | if (oldKey == null) { 151 | node.removeChild(oldVKid.node); 152 | } 153 | oldHead++; 154 | continue; 155 | } 156 | 157 | if (newKey == null || oldVNode.type === SSR_NODE) { 158 | if (oldKey == null) { 159 | patch(node, oldVKid && oldVKid.node, oldVKid, newVKids[newHead], isSvg); 160 | newHead++; 161 | } 162 | oldHead++; 163 | } else { 164 | if (oldKey === newKey) { 165 | patch(node, oldVKid.node, oldVKid, newVKids[newHead], isSvg); 166 | newKeyed[newKey] = true; 167 | oldHead++; 168 | } else { 169 | if ((tmpVKid = keyed[newKey]) != null) { 170 | patch( 171 | node, 172 | node.insertBefore(tmpVKid.node, oldVKid && oldVKid.node), 173 | tmpVKid, 174 | newVKids[newHead], 175 | isSvg, 176 | ); 177 | newKeyed[newKey] = true; 178 | } else { 179 | patch(node, oldVKid && oldVKid.node, null, newVKids[newHead], isSvg); 180 | } 181 | } 182 | newHead++; 183 | } 184 | } 185 | 186 | while (oldHead <= oldTail) { 187 | if (getKey((oldVKid = oldVKids[oldHead++])) == null) { 188 | node.removeChild(oldVKid.node); 189 | } 190 | } 191 | 192 | for (var i in keyed) { 193 | if (newKeyed[i] == null) { 194 | node.removeChild(keyed[i].node); 195 | } 196 | } 197 | } 198 | } 199 | 200 | return (newVNode.node = node); 201 | }; 202 | 203 | var propsChanged = (a, b) => { 204 | for (var i in a) if (!(i in b)) return true; 205 | for (var i in b) if (a[i] !== b[i]) return true; 206 | return false; 207 | }; 208 | 209 | var maybeVNode = (newVNode, oldVNode) => 210 | newVNode !== true && newVNode !== false && newVNode 211 | ? typeof newVNode.tag === 'function' 212 | ? ((!oldVNode || oldVNode.memo == null || propsChanged(oldVNode.memo, newVNode.memo)) && 213 | ((oldVNode = newVNode.tag(newVNode.memo)).memo = newVNode.memo), 214 | oldVNode) 215 | : newVNode 216 | : text(''); 217 | 218 | var recycleNode = (node) => 219 | node.nodeType === TEXT_NODE 220 | ? text(node.nodeValue, node) 221 | : createVNode( 222 | node.nodeName.toLowerCase(), 223 | EMPTY_OBJ, 224 | map.call(node.childNodes, recycleNode), 225 | SSR_NODE, 226 | node, 227 | ); 228 | 229 | var createVNode = (tag, { key, ...props }, children, type, node) => ({ 230 | tag, 231 | props, 232 | key, 233 | children, 234 | type, 235 | node, 236 | }); 237 | 238 | export var memo = (tag, memo) => ({ tag, memo }); 239 | 240 | export var text = (value, node) => createVNode(value, EMPTY_OBJ, EMPTY_ARR, TEXT_NODE, node); 241 | 242 | export var h = (tag, props, children = EMPTY_ARR) => 243 | createVNode(tag, props, isArray(children) ? children : [children]); 244 | 245 | export var app = ({ node, view }) => { 246 | var vdom = node && recycleNode(node); 247 | var render = () => (node = patch(node.parentNode, node, vdom, (vdom = view(render)), false)); 248 | 249 | render(); 250 | return render; 251 | }; 252 | -------------------------------------------------------------------------------- /src_templates/scripts/search.js: -------------------------------------------------------------------------------- 1 | import * as FlexSearch from '@akryum/flexsearch-es'; 2 | 3 | import { app, h, memo, text } from './dependencies/hyperapp.js'; 4 | 5 | /** @typedef {[rkey: string, text: string, timestamp: number, flags: number] & { idx: number }} PostEntry */ 6 | 7 | /** @type {FlexSearch.Document} */ 8 | const index = new FlexSearch.Document({ 9 | document: { 10 | id: '0', 11 | index: ['1'], 12 | store: true, 13 | }, 14 | }); 15 | 16 | // Add posts to document 17 | { 18 | /** @type {PostEntry[]} */ 19 | let entries; 20 | 21 | // Grab the JSON that's been embedded into the page 22 | { 23 | /** @type {HTMLScriptElement} */ 24 | const node = document.getElementById('search-json'); 25 | node.remove(); 26 | 27 | entries = JSON.parse(node.textContent); 28 | } 29 | 30 | // Go through the entries and add them all. 31 | { 32 | for (let i = 0, il = entries.length; i < il; i++) { 33 | /** @type {PostEntry} */ 34 | const entry = entries[i]; 35 | index.add(entry); 36 | } 37 | } 38 | } 39 | 40 | // Render our UI 41 | { 42 | const abs_with_time = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeStyle: 'short' }); 43 | 44 | const HAS_EMBED_IMAGE = 1 << 0; 45 | const HAS_EMBED_LINK = 1 << 1; 46 | const HAS_EMBED_RECORD = 1 << 2; 47 | const HAS_EMBED_FEED = 1 << 3; 48 | const HAS_EMBED_LIST = 1 << 4; 49 | 50 | const SORT_RELEVANT = 'relevant'; 51 | const SORT_NEW = 'new'; 52 | const SORT_OLD = 'old'; 53 | 54 | /** @type {PostEntry[]} */ 55 | let results = []; 56 | let sort = SORT_RELEVANT; 57 | 58 | const rerender = app({ 59 | node: document.getElementById('root'), 60 | view() { 61 | return h('div', {}, [ 62 | h('div', { class: 'SearchPage__header' }, [ 63 | h('input', { class: 'SearchPage__input', oninput: handle_search_input }), 64 | ]), 65 | 66 | h('div', { class: 'Filters' }, [ 67 | render_filter_btn(SORT_RELEVANT, 'Relevant'), 68 | render_filter_btn(SORT_NEW, 'New'), 69 | render_filter_btn(SORT_OLD, 'Old'), 70 | ]), 71 | 72 | h( 73 | 'div', 74 | {}, 75 | results.map((item) => 76 | h('div', { class: 'SearchItem', key: item[0] }, [memo(render_search_item, { item: item })]), 77 | ), 78 | ), 79 | ]); 80 | }, 81 | }); 82 | 83 | function handle_filter_button(ev) { 84 | if (sort !== (sort = ev.target.value)) { 85 | sort_results(); 86 | rerender(); 87 | } 88 | } 89 | 90 | function handle_search_input(ev) { 91 | const [search_results] = index.search(ev.target.value, 200, { enrich: true }); 92 | 93 | results = []; 94 | 95 | if (search_results !== undefined) { 96 | const res = search_results.result; 97 | for (let i = 0, ilen = res.length; i < ilen; i++) { 98 | const doc = res[i].doc; 99 | doc.idx = i; 100 | 101 | results.push(doc); 102 | } 103 | 104 | if (sort !== SORT_RELEVANT) { 105 | sort_results(); 106 | } 107 | } 108 | 109 | rerender(); 110 | } 111 | 112 | function sort_results() { 113 | if (sort === SORT_RELEVANT) { 114 | results.sort((a, b) => a.idx - b.idx); 115 | } else if (sort === SORT_NEW) { 116 | results.sort((a, b) => b[2] - a[2]); 117 | } else if (sort === SORT_OLD) { 118 | results.sort((a, b) => a[2] - b[2]); 119 | } 120 | } 121 | 122 | function render_filter_btn(val, label) { 123 | const active = sort === val; 124 | const cn = 'Interactive Interactive--primary Filter' + (active ? ' Filter--active' : ''); 125 | 126 | return h('button', { value: val, class: cn, onclick: handle_filter_button }, text(label)); 127 | } 128 | 129 | function render_search_item({ item }) { 130 | const [rkey, post_text, ts, flags] = item; 131 | 132 | return h('div', { class: 'SearchItem__content' }, [ 133 | h('a', { href: `posts/${rkey}.html`, class: 'SearchItem__timestamp' }, [ 134 | text(ts === 0 ? 'N/A' : abs_with_time.format(ts)), 135 | ]), 136 | h('p', { class: 'SearchItem__body' }, [text(post_text)]), 137 | (flags & HAS_EMBED_IMAGE) !== 0 && h('p', { class: 'SearchItem__accessory' }, text('[image]')), 138 | ]); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src_templates/styles/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: dark; 3 | overflow-y: scroll; 4 | } 5 | 6 | *:focus-visible { 7 | @apply outline outline-2 outline-blue-500/100; 8 | } 9 | 10 | .Root { 11 | @apply min-h-screen bg-neutral-950/100 text-sm text-white/100; 12 | } 13 | 14 | .Page { 15 | @apply mx-auto flex min-h-screen max-w-xl flex-col sm:border-x; 16 | } 17 | .PageHeader { 18 | @apply box-content flex h-[48px] items-center gap-[16px] border-b px-[16px]; 19 | } 20 | 21 | .Interactive { 22 | @apply cursor-pointer; 23 | } 24 | .Interactive:hover { 25 | @apply bg-neutral-600/10; 26 | } 27 | .Interactive--primary:hover { 28 | @apply bg-neutral-600/30; 29 | } 30 | 31 | .Link { 32 | @apply text-blue-400/100 hover:underline; 33 | } 34 | .Mention { 35 | @apply text-blue-400/100 hover:underline; 36 | } 37 | .Hashtag { 38 | @apply text-neutral-400/100; 39 | } 40 | 41 | .Filters { 42 | @apply flex flex-wrap items-center gap-[8px] p-[16px]; 43 | } 44 | .Filter { 45 | @apply rounded px-[8px] py-[4px] font-medium; 46 | } 47 | .Filter--active { 48 | @apply bg-neutral-700/100; 49 | } 50 | -------------------------------------------------------------------------------- /src_templates/styles/components/Embed.css: -------------------------------------------------------------------------------- 1 | .Embed { 2 | @apply mt-[12px] flex flex-col gap-[12px]; 3 | } 4 | .Embed:empty { 5 | @apply hidden; 6 | } 7 | -------------------------------------------------------------------------------- /src_templates/styles/components/FeedPost.css: -------------------------------------------------------------------------------- 1 | .FeedPost { 2 | } 3 | 4 | /** Context */ 5 | .FeedPost__context { 6 | @apply relative flex flex-col px-[16px]; 7 | } 8 | .FeedPost__contextItem { 9 | @apply mb-[4px] mt-[-4px] flex items-center pl-[52px] text-[13px] text-neutral-400/100; 10 | } 11 | .FeedPost__contextLine { 12 | @apply absolute bottom-[2px] left-[35px] top-0 border-l-2 border-dashed; 13 | } 14 | .FeedPost__contextText { 15 | @apply overflow-hidden text-ellipsis font-medium hover:underline; 16 | } 17 | 18 | /** Content */ 19 | .FeedPost__content { 20 | @apply relative flex gap-[12px] px-[16px]; 21 | } 22 | 23 | .FeedPost__aside { 24 | @apply shrink-0; 25 | } 26 | 27 | .FeedPost__avatarContainer { 28 | @apply h-[40px] w-[40px] overflow-hidden rounded-full bg-neutral-500/100; 29 | } 30 | .FeedPost__avatar { 31 | @apply h-full w-full; 32 | } 33 | 34 | .FeedPost__hasNextLine { 35 | @apply absolute bottom-[-14px] left-[35px] top-[42px] border-l-2; 36 | } 37 | 38 | .FeedPost__main { 39 | @apply flex min-w-0 grow flex-col; 40 | } 41 | 42 | .FeedPost__header { 43 | @apply mb-[2px] flex items-center text-neutral-400/100; 44 | } 45 | .FeedPost__nameContainer { 46 | @apply flex max-w-full gap-1 overflow-hidden text-ellipsis whitespace-nowrap; 47 | } 48 | .FeedPost__displayNameContainer { 49 | @apply overflow-hidden text-ellipsis; 50 | } 51 | .FeedPost__displayName { 52 | @apply font-bold text-white/100; 53 | } 54 | .FeedPost__handle { 55 | @apply block overflow-hidden text-ellipsis whitespace-nowrap; 56 | } 57 | 58 | .FeedPost__dot { 59 | @apply select-none px-[4px]; 60 | } 61 | .FeedPost__date { 62 | @apply whitespace-nowrap hover:underline; 63 | } 64 | 65 | .FeedPost__body { 66 | @apply whitespace-pre-wrap break-words; 67 | } 68 | 69 | .FeedPost__replies { 70 | @apply mt-[2px]; 71 | } 72 | -------------------------------------------------------------------------------- /src_templates/styles/components/PermalinkPost.css: -------------------------------------------------------------------------------- 1 | .PermalinkPost { 2 | @apply p-[16px]; 3 | } 4 | 5 | .PermalinkPost__header { 6 | @apply mb-[12px] flex items-center text-neutral-400/100; 7 | } 8 | 9 | .PermalinkPost__avatarContainer { 10 | @apply mr-[12px] h-[40px] w-[40px] shrink-0 overflow-hidden rounded-full bg-neutral-500/100; 11 | } 12 | .PermalinkPost__avatar { 13 | @apply h-full w-full object-cover; 14 | } 15 | 16 | .PermalinkPost__nameContainer { 17 | @apply block max-w-full overflow-hidden text-ellipsis whitespace-nowrap; 18 | } 19 | .PermalinkPost__displayNameContainer { 20 | @apply overflow-hidden text-ellipsis; 21 | } 22 | .PermalinkPost__displayName { 23 | @apply font-bold text-white/100; 24 | } 25 | .PermalinkPost__handle { 26 | @apply block overflow-hidden text-ellipsis whitespace-nowrap; 27 | } 28 | 29 | .PermalinkPost__body { 30 | @apply mt-[12px] overflow-hidden whitespace-pre-wrap break-words text-base; 31 | } 32 | .PermalinkPost__body:empty { 33 | @apply hidden; 34 | } 35 | 36 | .PermalinkPost__tags { 37 | @apply mt-[12px] flex flex-wrap gap-[6px]; 38 | } 39 | .PermalinkPost__tag { 40 | @apply flex min-w-0 items-center gap-[4px] rounded-full bg-neutral-900/100 px-[8px] leading-6; 41 | } 42 | .PermalinkPost__tagText { 43 | @apply overflow-hidden text-ellipsis whitespace-nowrap; 44 | } 45 | 46 | .PermalinkPost__footer { 47 | @apply mt-[12px]; 48 | } 49 | .PermalinkPost__date { 50 | @apply text-neutral-400/100; 51 | } 52 | -------------------------------------------------------------------------------- /src_templates/styles/components/ReplyPost.css: -------------------------------------------------------------------------------- 1 | .ReplyPost { 2 | @apply relative flex gap-[12px] px-[12px]; 3 | } 4 | 5 | .ReplyPost__aside { 6 | @apply shrink-0; 7 | } 8 | 9 | .ReplyPost__avatarContainer { 10 | @apply h-[20px] w-[20px] overflow-hidden rounded-full bg-neutral-500/100; 11 | } 12 | .ReplyPost__avatar { 13 | @apply h-full w-full object-cover; 14 | } 15 | 16 | .ReplyPost__hasChildrenLine { 17 | /* @apply absolute bottom-0 left-[21px] top-[28px] border-l-2; */ 18 | @apply absolute bottom-0 left-[21px] top-[22px] border-l-2; 19 | } 20 | .ReplyPost__hasParentLine { 21 | /* @apply absolute left-[-11px] top-[-12px] h-[24px] w-[15px] rounded-bl-[8px] border-b-[2px] border-l-[2px]; */ 22 | @apply absolute left-[-1px] top-[-12px] h-[23px] w-[12px] rounded-bl-[8px] border-b-[2px] border-l-[2px]; 23 | } 24 | 25 | .ReplyPost__main { 26 | @apply min-w-0 grow; 27 | } 28 | 29 | .ReplyPost__header { 30 | @apply mb-[2px] flex items-center text-neutral-400/100; 31 | } 32 | .ReplyPost__nameContainer { 33 | @apply flex max-w-full gap-1 overflow-hidden text-ellipsis whitespace-nowrap; 34 | } 35 | .ReplyPost__displayNameContainer { 36 | @apply overflow-hidden text-ellipsis; 37 | } 38 | .ReplyPost__displayName { 39 | @apply font-bold text-white/100; 40 | } 41 | .ReplyPost__handle { 42 | @apply block overflow-hidden text-ellipsis whitespace-nowrap; 43 | } 44 | .ReplyPost__dot { 45 | @apply select-none px-[4px]; 46 | } 47 | .ReplyPost__datetime { 48 | @apply whitespace-nowrap hover:underline; 49 | } 50 | 51 | .ReplyPost__body { 52 | @apply whitespace-pre-wrap break-words; 53 | } 54 | -------------------------------------------------------------------------------- /src_templates/styles/components/ReplyTree.css: -------------------------------------------------------------------------------- 1 | .ReplyTree { 2 | @apply relative flex flex-col; 3 | } 4 | 5 | .ReplyTree__hasSiblingLine { 6 | /* @apply absolute bottom-0 left-[-11px] top-0 border-l-2; */ 7 | @apply absolute bottom-0 left-[-1px] top-0 border-l-2; 8 | } 9 | 10 | .ReplyTree__children { 11 | /* @apply ml-[32px] mt-3 flex flex-col gap-3; */ 12 | @apply ml-[22px] mt-[12px] flex flex-col gap-[12px]; 13 | } 14 | 15 | .ReplyTree__hasMore { 16 | @apply relative pl-[14px]; 17 | } 18 | .ReplyTree__hasMoreText { 19 | @apply font-medium; 20 | } 21 | .ReplyTree__hasMoreLine { 22 | @apply absolute left-[-1px] top-[-12px] h-[23px] w-[12px] rounded-bl-[8px] border-b-[2px] border-l-[2px]; 23 | } 24 | 25 | .ReplyTree__mobileOnly { 26 | @apply block; 27 | } 28 | .ReplyTree__desktopOnly { 29 | @apply hidden; 30 | } 31 | 32 | @media (min-width: 432px) { 33 | .ReplyTree__mobileOnly { 34 | @apply hidden; 35 | } 36 | .ReplyTree__desktopOnly { 37 | @apply block; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src_templates/styles/components/embeds/EmbedFeed.css: -------------------------------------------------------------------------------- 1 | .EmbedFeed { 2 | @apply flex gap-[12px] rounded-md border p-[12px]; 3 | } 4 | 5 | .EmbedFeed__avatarContainer { 6 | @apply mt-[2px] h-[36px] w-[36px] overflow-hidden rounded-md bg-neutral-500/100; 7 | } 8 | .EmbedFeed__avatar { 9 | @apply h-full w-full object-cover; 10 | } 11 | 12 | .EmbedFeed__main { 13 | } 14 | .EmbedFeed__name { 15 | @apply font-bold; 16 | } 17 | .EmbedFeed__type { 18 | @apply text-neutral-400/100; 19 | } 20 | -------------------------------------------------------------------------------- /src_templates/styles/components/embeds/EmbedImage.css: -------------------------------------------------------------------------------- 1 | .EmbedImage { 2 | } 3 | .EmbedImage--bordered { 4 | @apply overflow-hidden rounded-md border; 5 | } 6 | .EmbedImage--standalone { 7 | @apply max-w-full self-baseline; 8 | } 9 | 10 | .EmbedImage__grid { 11 | @apply flex aspect-video gap-[2px]; 12 | } 13 | .EmbedImage__col { 14 | @apply flex grow basis-0 flex-col gap-[2px]; 15 | } 16 | 17 | .EmbedImage__imageContainer { 18 | @apply relative; 19 | } 20 | .EmbedImage__imageContainer--multiple { 21 | @apply min-h-0 grow basis-0 overflow-hidden; 22 | } 23 | .EmbedImage__imageContainer--standalone { 24 | @apply aspect-video overflow-hidden; 25 | } 26 | .EmbedImage__imageContainer--standaloneRatio { 27 | @apply max-h-[320px] min-h-[64px] min-w-[64px] max-w-full overflow-hidden; 28 | } 29 | 30 | .EmbedImage__image { 31 | @apply h-full w-full object-cover text-[0px]; 32 | } 33 | -------------------------------------------------------------------------------- /src_templates/styles/components/embeds/EmbedLink.css: -------------------------------------------------------------------------------- 1 | .EmbedLink { 2 | @apply flex overflow-hidden rounded-md border; 3 | } 4 | 5 | .EmbedLink__thumb { 6 | @apply box-content aspect-square w-[86px] shrink-0 border-r object-cover sm:w-[120px]; 7 | } 8 | 9 | .EmbedLink__main { 10 | @apply flex min-w-0 flex-col justify-center gap-[2px] p-[12px]; 11 | } 12 | .EmbedLink__domain { 13 | @apply overflow-hidden text-ellipsis text-neutral-400/100; 14 | } 15 | .EmbedLink__title { 16 | @apply line-clamp-2; 17 | } 18 | .EmbedLink__title:empty { 19 | @apply hidden; 20 | } 21 | 22 | .EmbedLink__desktopOnly { 23 | @apply hidden sm:block; 24 | } 25 | .EmbedLink__summary { 26 | @apply line-clamp-2 text-neutral-400/100; 27 | } 28 | .EmbedLink__summary:empty { 29 | @apply hidden; 30 | } 31 | -------------------------------------------------------------------------------- /src_templates/styles/components/embeds/EmbedList.css: -------------------------------------------------------------------------------- 1 | .EmbedList { 2 | @apply flex gap-[12px] rounded-md border p-[12px]; 3 | } 4 | 5 | .EmbedList__avatarContainer { 6 | @apply mt-[2px] h-[36px] w-[36px] overflow-hidden rounded-md bg-neutral-500/100; 7 | } 8 | .EmbedList__avatar { 9 | @apply h-full w-full object-cover; 10 | } 11 | 12 | .EmbedList__main { 13 | } 14 | .EmbedList__name { 15 | @apply font-bold; 16 | } 17 | .EmbedList__type { 18 | @apply text-neutral-400/100; 19 | } 20 | -------------------------------------------------------------------------------- /src_templates/styles/components/embeds/EmbedNotFound.css: -------------------------------------------------------------------------------- 1 | .EmbedNotFound { 2 | @apply rounded-md border p-[12px]; 3 | } 4 | 5 | .EmbedNotFound__self { 6 | @apply text-neutral-400/100; 7 | } 8 | .EmbedNotFound__other { 9 | @apply font-bold; 10 | } 11 | -------------------------------------------------------------------------------- /src_templates/styles/components/embeds/EmbedPost.css: -------------------------------------------------------------------------------- 1 | .EmbedPost { 2 | @apply overflow-hidden rounded-md border; 3 | } 4 | 5 | .EmbedPost__header { 6 | @apply mx-[12px] mt-[12px] flex text-neutral-400/100; 7 | } 8 | 9 | .EmbedPost__avatarContainer { 10 | @apply mr-[4px] h-[20px] w-[20px] shrink-0 overflow-hidden rounded-full bg-neutral-500/100; 11 | } 12 | .EmbedPost__avatar { 13 | @apply h-full w-full; 14 | } 15 | 16 | .EmbedPost__nameContainer { 17 | @apply flex max-w-full gap-[4px] overflow-hidden text-ellipsis whitespace-nowrap; 18 | } 19 | .EmbedPost__displayNameContainer { 20 | @apply overflow-hidden text-ellipsis; 21 | } 22 | .EmbedPost__displayName { 23 | @apply font-bold text-white/100; 24 | } 25 | .EmbedPost__handle { 26 | @apply block overflow-hidden text-ellipsis whitespace-nowrap; 27 | } 28 | 29 | .EmbedPost__dot { 30 | @apply select-none px-1; 31 | } 32 | .EmbedPost__date { 33 | @apply whitespace-nowrap; 34 | } 35 | 36 | .EmbedPost__body { 37 | @apply flex items-start; 38 | } 39 | .EmbedPost__imageAside { 40 | @apply mb-[12px] ml-[12px] mt-[8px] grow basis-0; 41 | } 42 | .EmbedPost__text { 43 | @apply mx-[12px] mb-[12px] mt-[4px] line-clamp-6 min-w-0 grow-[4] basis-0 whitespace-pre-wrap break-words; 44 | } 45 | .EmbedPost__text:empty { 46 | @apply hidden; 47 | } 48 | 49 | .EmbedPost__divider { 50 | @apply mt-[12px]; 51 | } 52 | -------------------------------------------------------------------------------- /src_templates/styles/index.css: -------------------------------------------------------------------------------- 1 | @import './reset.css'; 2 | @import './base.css'; 3 | 4 | @import './components/embeds/EmbedFeed.css'; 5 | @import './components/embeds/EmbedImage.css'; 6 | @import './components/embeds/EmbedLink.css'; 7 | @import './components/embeds/EmbedList.css'; 8 | @import './components/embeds/EmbedNotFound.css'; 9 | @import './components/embeds/EmbedPost.css'; 10 | @import './components/Embed.css'; 11 | @import './components/FeedPost.css'; 12 | @import './components/PermalinkPost.css'; 13 | @import './components/ReplyPost.css'; 14 | @import './components/ReplyTree.css'; 15 | 16 | @import './pages/SearchPage.css'; 17 | @import './pages/ThreadPage.css'; 18 | @import './pages/TimelinePage.css'; 19 | @import './pages/WelcomePage.css'; 20 | -------------------------------------------------------------------------------- /src_templates/styles/pages/SearchPage.css: -------------------------------------------------------------------------------- 1 | .SearchPage__noscript { 2 | @apply m-[16px] mb-0 rounded bg-yellow-900 px-[16px] py-[12px] font-medium; 3 | } 4 | .SearchPage__loading { 5 | @apply m-[16px] text-neutral-400/100; 6 | } 7 | 8 | .SearchPage__header { 9 | @apply p-[16px] pb-0; 10 | } 11 | 12 | .SearchPage__input { 13 | @apply w-full rounded border border-neutral-700 bg-neutral-900 px-[12px] py-[8px] leading-none; 14 | } 15 | .SearchPage__input::placeholder { 16 | @apply text-neutral-400/100; 17 | } 18 | 19 | .SearchItem { 20 | @apply mx-[16px] py-[16px]; 21 | } 22 | .SearchItem + .SearchItem { 23 | @apply border-t; 24 | } 25 | .SearchItem__content { 26 | } 27 | .SearchItem__timestamp { 28 | @apply mb-[2px] text-neutral-400/100 hover:underline; 29 | } 30 | .SearchItem__body { 31 | @apply whitespace-pre-wrap break-words; 32 | } 33 | .SearchItem__accessory { 34 | @apply mt-[2px]; 35 | } 36 | -------------------------------------------------------------------------------- /src_templates/styles/pages/ThreadPage.css: -------------------------------------------------------------------------------- 1 | .ThreadPage__descendants { 2 | @apply flex flex-col gap-[12px] py-[12px]; 3 | } 4 | .ThreadPage__descendants:empty { 5 | @apply hidden; 6 | } 7 | 8 | /** ThreadAncestors */ 9 | .ThreadAncestors { 10 | @apply pt-[16px]; 11 | } 12 | 13 | .ThreadAncestors__header { 14 | @apply relative mx-[16px] flex w-max cursor-pointer select-none list-none items-center gap-[8px] rounded px-[16px] py-[8px]; 15 | } 16 | 17 | .ThreadAncestors__accordionIcon { 18 | @apply ml-[-6px] h-[20px] w-[20px] shrink-0 text-neutral-300/100; 19 | rotate: 90deg; 20 | } 21 | .ThreadAncestors[open] .ThreadAncestors__accordionIcon { 22 | rotate: -90deg; 23 | } 24 | .ThreadAncestors__accordionText { 25 | } 26 | 27 | .ThreadAncestors__list { 28 | @apply mt-[16px] flex flex-col gap-[16px]; 29 | } 30 | 31 | /** ThreadCut */ 32 | .ThreadCut { 33 | @apply flex gap-[12px] px-[16px]; 34 | } 35 | 36 | .ThreadCut__aside { 37 | @apply relative w-[40px]; 38 | } 39 | .ThreadCut__line { 40 | @apply absolute bottom-[-14px] left-[19px] top-[12px] z-0 border-l-2 border-dashed; 41 | } 42 | 43 | .ThreadCut__main { 44 | @apply min-w-0 grow; 45 | } 46 | .ThreadCut__headerText { 47 | @apply font-medium text-neutral-400/100; 48 | } 49 | 50 | .ThreadCut__actions { 51 | @apply flex items-center gap-[8px] text-neutral-400/100; 52 | } 53 | .ThreadCut__actionSeparator { 54 | @apply select-none; 55 | } 56 | -------------------------------------------------------------------------------- /src_templates/styles/pages/TimelinePage.css: -------------------------------------------------------------------------------- 1 | .TimelinePage__feed { 2 | @apply flex flex-col gap-[16px] py-[16px]; 3 | } 4 | .TimelinePage__feedSeparator { 5 | @apply mx-[16px]; 6 | } 7 | 8 | .TimelinePage__pagination { 9 | @apply flex flex-wrap items-center justify-center gap-[8px] p-[16px]; 10 | } 11 | .TimelinePage__page { 12 | @apply rounded px-[10px] py-[4px] font-medium proportional-nums; 13 | } 14 | .TimelinePage__page--active { 15 | @apply bg-neutral-700; 16 | } 17 | .TimelinePage__page--disabled { 18 | @apply text-neutral-500/100; 19 | } 20 | .TimelinePage__pageIcon { 21 | @apply mx-[-6px] h-[20px] w-[20px]; 22 | } 23 | .TimelinePage__pageIcon--boundary { 24 | } 25 | .TimelinePage__pageIcon--prev { 26 | rotate: 180deg; 27 | } 28 | .TimelinePage__pageIcon--next { 29 | } 30 | -------------------------------------------------------------------------------- /src_templates/styles/pages/WelcomePage.css: -------------------------------------------------------------------------------- 1 | .WelcomePage__content { 2 | @apply grow p-[16px]; 3 | } 4 | .WelcomePage__header { 5 | @apply text-balance text-lg font-bold; 6 | } 7 | .WelcomePage__paragraph { 8 | @apply mt-[4px] text-pretty; 9 | } 10 | 11 | .WelcomePage__footer { 12 | @apply px-[16px] py-2 text-center text-[13px] text-neutral-400/100; 13 | } 14 | -------------------------------------------------------------------------------- /src_templates/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* Tailwind's Preflight reset */ 2 | 3 | /* 4 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 5 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 6 | */ 7 | 8 | *, 9 | ::before, 10 | ::after { 11 | box-sizing: border-box; 12 | /* 1 */ 13 | border-width: 0; 14 | /* 2 */ 15 | border-style: solid; 16 | /* 2 */ 17 | border-color: rgb(64 64 64); /* neutral-700 */ 18 | /* 2 */ 19 | } 20 | 21 | ::before, 22 | ::after { 23 | --tw-content: ''; 24 | } 25 | 26 | /* 27 | 1. Use a consistent sensible line-height in all browsers. 28 | 2. Prevent adjustments of font size after orientation changes in iOS. 29 | 3. Use a more readable tab size. 30 | 4. Use the user's configured `sans` font-family by default. 31 | 5. Use the user's configured `sans` font-feature-settings by default. 32 | 6. Use the user's configured `sans` font-variation-settings by default. 33 | 7. Disable tap highlights on iOS 34 | */ 35 | 36 | html, 37 | :host { 38 | line-height: 1.5; 39 | /* 1 */ 40 | -webkit-text-size-adjust: 100%; 41 | /* 2 */ 42 | -moz-tab-size: 4; 43 | /* 3 */ 44 | tab-size: 4; 45 | /* 3 */ 46 | font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 47 | 'Noto Color Emoji'; 48 | /* 4 */ 49 | font-feature-settings: normal; 50 | /* 5 */ 51 | font-variation-settings: normal; 52 | /* 6 */ 53 | -webkit-tap-highlight-color: transparent; 54 | /* 7 */ 55 | } 56 | 57 | /* 58 | 1. Remove the margin in all browsers. 59 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 60 | */ 61 | 62 | body { 63 | margin: 0; 64 | /* 1 */ 65 | line-height: inherit; 66 | /* 2 */ 67 | } 68 | 69 | /* 70 | 1. Add the correct height in Firefox. 71 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 72 | 3. Ensure horizontal rules are visible by default. 73 | */ 74 | 75 | hr { 76 | height: 0; 77 | /* 1 */ 78 | color: inherit; 79 | /* 2 */ 80 | border-top-width: 1px; 81 | /* 3 */ 82 | } 83 | 84 | /* 85 | Add the correct text decoration in Chrome, Edge, and Safari. 86 | */ 87 | 88 | abbr:where([title]) { 89 | text-decoration: underline dotted; 90 | } 91 | 92 | /* 93 | Remove the default font size and weight for headings. 94 | */ 95 | 96 | h1, 97 | h2, 98 | h3, 99 | h4, 100 | h5, 101 | h6 { 102 | font-size: inherit; 103 | font-weight: inherit; 104 | } 105 | 106 | /* 107 | Reset links to optimize for opt-in styling instead of opt-out. 108 | */ 109 | 110 | a { 111 | color: inherit; 112 | text-decoration: inherit; 113 | } 114 | 115 | /* 116 | Add the correct font weight in Edge and Safari. 117 | */ 118 | 119 | b, 120 | strong { 121 | font-weight: bolder; 122 | } 123 | 124 | /* 125 | 1. Use the user's configured `mono` font-family by default. 126 | 2. Use the user's configured `mono` font-feature-settings by default. 127 | 3. Use the user's configured `mono` font-variation-settings by default. 128 | 4. Correct the odd `em` font sizing in all browsers. 129 | */ 130 | 131 | code, 132 | kbd, 133 | samp, 134 | pre { 135 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', 136 | monospace; 137 | /* 1 */ 138 | font-feature-settings: normal; 139 | /* 2 */ 140 | font-variation-settings: normal; 141 | /* 3 */ 142 | font-size: 1em; 143 | /* 4 */ 144 | } 145 | 146 | /* 147 | Add the correct font size in all browsers. 148 | */ 149 | 150 | small { 151 | font-size: 80%; 152 | } 153 | 154 | /* 155 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 156 | */ 157 | 158 | sub, 159 | sup { 160 | font-size: 75%; 161 | line-height: 0; 162 | position: relative; 163 | vertical-align: baseline; 164 | } 165 | 166 | sub { 167 | bottom: -0.25em; 168 | } 169 | 170 | sup { 171 | top: -0.5em; 172 | } 173 | 174 | /* 175 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 176 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 177 | 3. Remove gaps between table borders by default. 178 | */ 179 | 180 | table { 181 | text-indent: 0; 182 | /* 1 */ 183 | border-color: inherit; 184 | /* 2 */ 185 | border-collapse: collapse; 186 | /* 3 */ 187 | } 188 | 189 | /* 190 | 1. Change the font styles in all browsers. 191 | 2. Remove the margin in Firefox and Safari. 192 | 3. Remove default padding in all browsers. 193 | */ 194 | 195 | button, 196 | input, 197 | optgroup, 198 | select, 199 | textarea { 200 | font-family: inherit; 201 | /* 1 */ 202 | font-feature-settings: inherit; 203 | /* 1 */ 204 | font-variation-settings: inherit; 205 | /* 1 */ 206 | font-size: 100%; 207 | /* 1 */ 208 | font-weight: inherit; 209 | /* 1 */ 210 | line-height: inherit; 211 | /* 1 */ 212 | color: inherit; 213 | /* 1 */ 214 | margin: 0; 215 | /* 2 */ 216 | padding: 0; 217 | /* 3 */ 218 | } 219 | 220 | /* 221 | Remove the inheritance of text transform in Edge and Firefox. 222 | */ 223 | 224 | button, 225 | select { 226 | text-transform: none; 227 | } 228 | 229 | /* 230 | 1. Correct the inability to style clickable types in iOS and Safari. 231 | 2. Remove default button styles. 232 | */ 233 | 234 | button, 235 | [type='button'], 236 | [type='reset'], 237 | [type='submit'] { 238 | -webkit-appearance: button; 239 | /* 1 */ 240 | background-color: transparent; 241 | /* 2 */ 242 | background-image: none; 243 | /* 2 */ 244 | } 245 | 246 | /* 247 | Use the modern Firefox focus style for all focusable elements. 248 | */ 249 | 250 | :-moz-focusring { 251 | outline: auto; 252 | } 253 | 254 | /* 255 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 256 | */ 257 | 258 | :-moz-ui-invalid { 259 | box-shadow: none; 260 | } 261 | 262 | /* 263 | Add the correct vertical alignment in Chrome and Firefox. 264 | */ 265 | 266 | progress { 267 | vertical-align: baseline; 268 | } 269 | 270 | /* 271 | Correct the cursor style of increment and decrement buttons in Safari. 272 | */ 273 | 274 | ::-webkit-inner-spin-button, 275 | ::-webkit-outer-spin-button { 276 | height: auto; 277 | } 278 | 279 | /* 280 | 1. Correct the odd appearance in Chrome and Safari. 281 | 2. Correct the outline style in Safari. 282 | */ 283 | 284 | [type='search'] { 285 | -webkit-appearance: textfield; 286 | /* 1 */ 287 | outline-offset: -2px; 288 | /* 2 */ 289 | } 290 | 291 | /* 292 | Remove the inner padding in Chrome and Safari on macOS. 293 | */ 294 | 295 | ::-webkit-search-decoration { 296 | -webkit-appearance: none; 297 | } 298 | 299 | /* 300 | 1. Correct the inability to style clickable types in iOS and Safari. 301 | 2. Change font properties to `inherit` in Safari. 302 | */ 303 | 304 | ::-webkit-file-upload-button { 305 | -webkit-appearance: button; 306 | /* 1 */ 307 | font: inherit; 308 | /* 2 */ 309 | } 310 | 311 | /* 312 | Add the correct display in Chrome and Safari. 313 | */ 314 | 315 | summary { 316 | display: list-item; 317 | } 318 | 319 | /* 320 | Removes the default spacing and border for appropriate elements. 321 | */ 322 | 323 | blockquote, 324 | dl, 325 | dd, 326 | h1, 327 | h2, 328 | h3, 329 | h4, 330 | h5, 331 | h6, 332 | hr, 333 | figure, 334 | p, 335 | pre { 336 | margin: 0; 337 | } 338 | 339 | fieldset { 340 | margin: 0; 341 | padding: 0; 342 | } 343 | 344 | legend { 345 | padding: 0; 346 | } 347 | 348 | ol, 349 | ul, 350 | menu { 351 | list-style: none; 352 | margin: 0; 353 | padding: 0; 354 | } 355 | 356 | /* 357 | Reset default styling for dialogs. 358 | */ 359 | 360 | dialog { 361 | padding: 0; 362 | } 363 | 364 | /* 365 | Prevent resizing textareas horizontally by default. 366 | */ 367 | 368 | textarea { 369 | resize: vertical; 370 | } 371 | 372 | /* 373 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 374 | 2. Set the default placeholder color to the user's configured gray 400 color. 375 | */ 376 | 377 | input::placeholder, 378 | textarea::placeholder { 379 | opacity: 1; 380 | /* 1 */ 381 | color: #9ca3af; 382 | /* 2 */ 383 | } 384 | 385 | /* 386 | Set the default cursor for buttons. 387 | */ 388 | 389 | button, 390 | [role='button'] { 391 | cursor: pointer; 392 | } 393 | 394 | /* 395 | Make sure disabled buttons don't get the pointer cursor. 396 | */ 397 | 398 | :disabled { 399 | cursor: default; 400 | } 401 | 402 | /* 403 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 404 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 405 | This can trigger a poorly considered lint error in some tools but is included by design. 406 | */ 407 | 408 | img, 409 | svg, 410 | video, 411 | canvas, 412 | audio, 413 | iframe, 414 | embed, 415 | object { 416 | display: block; 417 | /* 1 */ 418 | vertical-align: middle; 419 | /* 2 */ 420 | } 421 | 422 | /* 423 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 424 | */ 425 | 426 | img, 427 | video { 428 | max-width: 100%; 429 | height: auto; 430 | } 431 | 432 | /* Make elements with the HTML hidden attribute stay hidden by default */ 433 | 434 | [hidden] { 435 | display: none; 436 | } 437 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import forms from '@tailwindcss/forms'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | './index.html', 7 | './export.html', 8 | './src/index.ts', 9 | './src/controllers/*.ts', 10 | './src/utils/logger.tsx', 11 | ], 12 | theme: { 13 | extend: {}, 14 | }, 15 | plugins: [forms()], 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": [], 6 | "skipLibCheck": true, 7 | 8 | "module": "ESNext", 9 | "moduleResolution": "Bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "noEmit": true, 13 | "jsx": "preserve", 14 | "jsxImportSource": "@intrnl/jsx-to-string", 15 | 16 | "incremental": true, 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "verbatimModuleSyntax": true, 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }], 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | import babel from '@rollup/plugin-babel'; 4 | 5 | import min from '@minify-html/node'; 6 | 7 | export default defineConfig({ 8 | base: '/skeetgen/', 9 | optimizeDeps: { 10 | include: ['@intrnl/jsx-to-string/runtime'], 11 | }, 12 | build: { 13 | minify: 'terser', 14 | sourcemap: true, 15 | target: 'esnext', 16 | modulePreload: { 17 | polyfill: false, 18 | }, 19 | rollupOptions: { 20 | input: { 21 | index: './index.html', 22 | export: './export.html', 23 | }, 24 | }, 25 | terserOptions: { 26 | compress: { 27 | passes: 2, 28 | }, 29 | }, 30 | }, 31 | esbuild: { 32 | target: 'es2022', 33 | }, 34 | plugins: [ 35 | { 36 | enforce: 'pre', 37 | ...babel({ 38 | babelrc: false, 39 | babelHelpers: 'bundled', 40 | extensions: ['.tsx'], 41 | plugins: [['@babel/plugin-syntax-typescript', { isTSX: true }], ['@intrnl/jsx-to-string/babel']], 42 | }), 43 | }, 44 | { 45 | name: 'minify-html', 46 | transformIndexHtml(source) { 47 | const encoder = new TextEncoder(); 48 | const decoder = new TextDecoder(); 49 | 50 | const buffer = min.minify(encoder.encode(source), {}); 51 | 52 | return { tags: [], html: decoder.decode(buffer) }; 53 | }, 54 | }, 55 | ], 56 | }); 57 | --------------------------------------------------------------------------------