├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── serve.json ├── src ├── assets │ ├── damion-v10.woff │ ├── damion-v10.woff2 │ └── favicon.ico ├── bookmarklet.pug ├── error.pug ├── index.pug ├── js │ ├── Bundler.ts │ ├── Gist.ts │ ├── Playground.ts │ ├── bookmarklet.ts │ ├── editor.ts │ ├── error.ts │ ├── home.ts │ ├── parsePath.ts │ └── router.ts ├── partials │ ├── about.pug │ ├── bookmarkletButton.pug │ ├── copyButton.pug │ ├── createForm.pug │ ├── editor.pug │ ├── error.pug │ ├── gistLink.pug │ ├── header.pug │ ├── homeLink.pug │ ├── kofiLink.pug │ ├── layout.pug │ ├── playgroundLink.pug │ ├── properties.pug │ ├── repoLink.pug │ ├── transpilationError.pug │ └── variables.pug └── style.css └── test ├── Bundler.test.ts ├── Gist.test.ts ├── Playground.test.ts ├── __mock__ ├── Alpine.ts ├── TextEncoder.ts ├── clipboard.ts ├── document.ts ├── esbuild.ts ├── fetch.ts └── location.ts ├── bookmarklet.test.ts ├── editor.test.ts ├── home.test.ts ├── parsePath.test.ts └── router.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | .parcel-cache 5 | coverage 6 | router.zip 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ashton Meuser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bookmarklet Platform 2 | 3 | A platform for building and distributing JavaScript gists as bookmarklets. Observable in the wild [here](https://bookmarkl.ink). 4 | 5 | ## Background 6 | 7 | ### Bookmarklets 8 | 9 | [Wikipedia](https://en.wikipedia.org/wiki/Bookmarklet) describes bookmarklets are described as follows: 10 | 11 | > A bookmarklet is a bookmark stored in a web browser that contains JavaScript commands that add new features to the browser. 12 | 13 | Bookmarklets are saved in the same way as standard website bookmarks. However, rather than navigating to a new website when the bookmark is clicked, a bookmarklet runs JavaScript code. This code is executed on the current website displayed by the browser and can have any number of effects. Examples might include sharing the current site to a social media platform, shortening a URL, or translating the language of the current website's text. 14 | 15 | ### Gists 16 | 17 | [Gist](https://gist.github.com) is a GitHub feature that provides [pastebin](https://en.wikipedia.org/wiki/Pastebin)-like functionality. Users can create code snippets called *gists*. This differs from GitHub itself which is used to host entire projects. 18 | 19 | Gist is unique in that it creates each gist as an individual [git repository](https://git-scm.com). That means that support for multiple files, versioning, and forking is built in. 20 | 21 | ## Using the Application 22 | 23 | ### Creating a Bookmarklet 24 | 25 | The bookmarkl.ink application can be used to generate and share bookmarklets. 26 | 27 | Before beginning, note that a GitHub account is required. If you don't have an account with GitHub, register [here](https://github.com/join). 28 | 29 | To create a bookmarklet, first [create a GitHub gist](https://gist.github.com) containing the intended source code for your bookmarklet. Once saved, copy the URL of your gist. If you'd like to simply test the application, you can create a gist with the following content. 30 | 31 | ```js 32 | window.alert("Hello, World!"); 33 | ``` 34 | 35 | Navigate to [bookmarkl.ink](https://bookmarkl.ink) and paste the gist URL into the input on the bottom of the page. Click the "Create" button. The resultant page is your generated bookmarklet. Clicking the button at the bottom of the page will run your bookmarklet's JavaScript source code. In many desktop web browsers, the bookmarklet can be saved by dragging the button to the browser's toolbar, typically just below the URL bar. 36 | 37 | The URL can be shared to allow others to run and/or save your bookmarklet. 38 | 39 | ### Customizing a Bookmarklet 40 | 41 | To add a title to the bookmarklet, include `// bookmarklet-title: [TITLE]` in your gist, replacing `[TITLE]` with your desired title. Similarly, a description for the bookmarklet can be added with `// bookmarklet-about: [ABOUT]`. 42 | 43 | To link to a specific version of the GitHub gist source code, you'll first need to get the commit hash from GitHub. One way to accomplish this is to view the raw source of your gist using the "•••" button in GitHub. In the URL, you should find a commit hash of 40 hexadecimal characters. A bookmarklet can be locked to this version of the source code by modifying the bookmarklet URL to the format `bookmarkl.ink/[USERNAME]/[ID]/[COMMIT]`, replacing the `[USERNAME]`, `[ID]`, and `[COMMIT]` placeholders with your gist's values. 44 | 45 | If you've created a multi-file gist, you can ensure your bookmarklet pulls its source code from a specific file by modifying the bookmarklet URL to the format `bookmarkl.ink/[USERNAME]/[ID]/[FILE]`, replacing the `[USERNAME]`, `[ID]`, and `[FILE]` placeholders with yours. The optional version commit hash can be excluded entirely (`bookmarkl.ink/[USERNAME]/[ID]/[FILE]`), included (`bookmarkl.ink/[USERNAME]/[ID]/[COMMIT]/[FILE]`), or left blank (`bookmarkl.ink/[USERNAME]/[ID]//[FILE]`). 46 | 47 | ### Including Variables 48 | 49 | If your bookmarklet requires further customization by the end user, you may include variables to be manually entered and compiled into the bookmarklet code. These can be included via comment in your gist in the format `// bookmarklet-var: [VARIABLE_NAME]`. Upon modifying variables at Bookmarkl.ink, variables are hard-coded into the compiled bookmarklet which can then be saved to your bookmarks. 50 | 51 | By default, variables are all strings, i.e. text inputs. You can force the input to be numeric or censored using `// bookmarklet-var(number): [VARIABLE_NAME]` and `// bookmarklet-var(password): [VARIABLE_NAME]`, respectively. Numeric variables are injected into the script as numbers while passwords are injected as strings. 52 | 53 | There also exist special values that can be injected into a bookmarklet. You can access the gist author and ID via `// bookmarklet-var(author): [VARIABLE_NAME]` and `// bookmarklet-var(id): [VARIABLE_NAME]`, respectively. You can access a Universally Unique Identifier (UUID v4) via `// bookmarklet-var(uuid): [VARIABLE_NAME]`. 54 | 55 | The following variable types are supported. 56 | 57 | Type | Description 58 | -- | -- 59 | `text` | Text input field injected as a string 60 | `password` | Password input field (i.e., censored) injected as a string 61 | `number` | Numeric input field injected as a number 62 | `boolean` | Checkbox input field injected as a boolean 63 | `author` | Author ID injected as a string; not editable 64 | `id` | Gist ID injected as a string; not editable 65 | `uuid` | UUID injected as a string at build time; not editable 66 | 67 | ## Examples 68 | 69 | Several example gists can be found [here](https://gist.github.com/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/). For the sake of completeness, these examples (and more) are highlighted bellow. 70 | 71 | Gist | Bookmarkl.ink | Description 72 | --|--|-- 73 | [count.js](https://gist.github.com/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/#file-count-js) | [Word Frequency](https://bookmarkl.ink/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/count.js) | A simple example showing a bookmarklet reading the content of whatever page it was called on. In this case, basic word frequency is shown. 74 | [emoji.js](https://gist.github.com/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/#file-emoji-js) | [Emoji!](https://bookmarkl.ink/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/emoji.js) | Adds a random emoji at a random position within the current website. 75 | [focus.js](https://gist.github.com/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/#file-focus-js) | [img--](https://bookmarkl.ink/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/focus.js) | Dims images until mouse hover. 76 | [qr.js](https://gist.github.com/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/#file-qr-js) | [QR Code](https://bookmarkl.ink/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/qr.js) | Creates a QR that links to the web page currently being viewed. 77 | [variables.js](https://gist.github.com/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/#file-variables-js) | [Test Variables](https://bookmarkl.ink/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/variables.js) | Showcases a bookmarklet requiring variables. These variables can be edited directly in Bookmarkl.ink and are then compiled into the bookmarklet code. 78 | [_import.ts](https://gist.github.com/ashtonmeuser/39e0cb3f2472cb8980726fb6c7d6d349) | [ESM Imports](https://bookmarkl.ink/ashtonmeuser/39e0cb3f2472cb8980726fb6c7d6d349) | Showcases importing remote and relative ESM modules. Also demonstrates bundling of different content types as well as specification of a custom loader. 79 | 80 | ## Design 81 | 82 | ### Technology Stack 83 | 84 | Bookmarkl.ink is hosted as a [static site](https://en.wikipedia.org/wiki/Static_web_page) i.e. files are served unaltered to all viewers. The static site is hosted in an [AWS S3](https://aws.amazon.com/s3/) bucket. To enable caching, the bucket is behind an [AWS CloudFront](https://aws.amazon.com/cloudfront/) distribution. 85 | 86 | Bookmarkl.ink uses [AlpineJS](https://alpinejs.dev) as a frontend framework. 87 | 88 | In order to reuse parts of markup, [Pug](https://pugjs.org/api/getting-started.html) is used. This allows writing small, reusable templates that are translated into HTML. [SASS](https://sass-lang.com), a CSS extension language, is used to write more sensible styles. In order to enable the transformation of Pug templates and SASS, a web application bundler called [Parcel](https://parceljs.org/getting_started.html) is used. 89 | 90 | ### URI Format 91 | 92 | Any URL deeper than root ([bookmarkl.ink](https://bookmarkl.ink)) is interpreted as a bookmarklet. Each bookmarklet URI references exactly one GitHub gist that supplies the source code of the bookmarklet. The URI should adhere to the format `bookmarkl.ink/AUTHOR/ID/[VERSION]/[FILE]`. 93 | 94 | Property | Required | Description 95 | -------- | -------- | --- 96 | Author | Yes | The username of the GitHub user that created a JavaScript gist. 97 | ID | Yes | A gist ID. This must be a 32-character hexadecimal string. 98 | Version | No | An optional version that represents the commit hash of the git repository underlying a GitHub gist. This must be a 40-character hexadecimal string. 99 | File | No | An optional file name within a multi-file GitHub gist. If this property is omitted, the first file added to the gist will be used. 100 | 101 | These properties, if provided, will be displayed as metadata belonging to the bookmarklet. 102 | 103 | ### Bookmarklet Metadata 104 | 105 | In addition to the properties that define the GitHub gist from which to pull a bookmarklet's source code (see above), users can define several properties in a gist itself. These additional properties are optional. 106 | 107 | So as not to alter the function of a bookmarklet, additional properties are defined in JavaScript comments using the format `[KEY]: [VALUE]` where `[VALUE]` is the value of the property and `[KEY]` is a key from the following table. 108 | 109 | Property | Key | Description 110 | ----------- | ------------------- | --- 111 | Title | `bookmarklet-title` | The title of the bookmarklet. This will be the default bookmark name that the browser assigns when the bookmarklet is saved. 112 | Description | `bookmarklet-about` | This field can be used to explain the purpose or function of the bookmarklet. 113 | 114 | ### JavaScript to Bookmarklet 115 | 116 | Theoretically, any valid JavaScript snippet should be able to run in a browser window. There are several steps, however, that must be performed to ensure snippets are able to be saved as bookmarklets. 117 | 118 | Bookmarklets are saved in the same way as standard bookmarks. That means that when clicked, the bookmark(let) address is inserted into the URL bar and evaluated. In the case of a standard bookmark, this results in the browser navigating to the saved HTTP address. Bookmarklets rely on the fact that browsers evaluate addresses prepended with `javascript:` differently. Instead of navigating to an address, the JavaScript is executed on the current window. 119 | 120 | Before transpilation and minification, dependencies are bundled. See [Bundling](#bundling) for more details. 121 | 122 | Because writers of JavaScript gists will often use features of JavaScript that are not supported by older browsers, a [transpilation](https://en.wikipedia.org/wiki/Source-to-source_compiler) step should occur. [esbuild](https://esbuild.github.io) is used to perform this transpilation. Additionally, in order to shorten the length of the source code stored in a bookmarklet, the code is minified. 123 | 124 | In order to properly store the bookmarklet in a bookmark, the transpiled, minified, source code is [URI encoded](https://www.w3schools.com/tags/ref_urlencode.ASP). 125 | 126 | Some browsers, namely Firefox, have limited support of bookmarklets due to security concerns. We can sidestep the limitations added by Firefox by wrapping the bookmarklet source code in an anonymous, self-executing function, sometimes called an [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE). 127 | 128 | Finally, the saved bookmarklet will take the following form. 129 | 130 | ``` 131 | javascript:(function () { 132 | ... 133 | Transpiled, minified, URI-encoded source code here 134 | ... 135 | })(); 136 | ``` 137 | 138 | ### Bundling 139 | 140 | To enable reuse of modules, bookmarkl.ink bundles dependencies using [esbuild](https://esbuild.github.io). This fetches dependencies, inlines them into the output JavaScript, and performs optimizations including dead code elimination. 141 | 142 | Only [static imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) are bundled. [Dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) remain unchanged and can be used to import modules at runtime rather than during bundling. 143 | 144 | The following content types are supported by default. 145 | 146 | Type | Extension | Behaviour 147 | --|--|-- 148 | JS | `.js`, `.mjs` | JavaScript content is inlined, bundled, and minified using esbuild's [JS loader](https://esbuild.github.io/content-types/#javascript). 149 | TS | `.ts`, `.mts` | TypeScript content is inlined, bundled, and minified using esbuild's [TS loader](https://esbuild.github.io/content-types/#typescript). 150 | JSX | `.jsx`, `.tsx` | JSX/TSX XML elements become JavaScript function calls using esbuild's [JSX loader](https://esbuild.github.io/content-types/#jsx). 151 | JSON | `.json` | JSON content is parsed into a JavaScript object and inlined using esbuild's [JSON loader](https://esbuild.github.io/content-types/#json). 152 | CSS | `.css` | CSS content is loaded, minified using esbuild's [CSS loader](https://esbuild.github.io/content-types/#css), and inlined as a JavaScript string using esbuild's [Text loader](https://esbuild.github.io/content-types/#text). 153 | Image | `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg` | Image data is loaded, encoded as Base64, and inlined as a data URL string using esbuild's [Data URL loader](https://esbuild.github.io/content-types/#data-url). 154 | Text | `.html`, `.txt`, `.md`, `.xml`, `.yml`, `.dat` | Text file content is loaded and inlined as a JavaScript string using esbuild's [Text loader](https://esbuild.github.io/content-types/#text). 155 | Binary | `.bin`, `.wasm` | Binary data is loaded, encoded as Base64, and decoded into a `Uint8Array` at runtime using esbuild's [Binary loader](https://esbuild.github.io/content-types/#binary). 156 | 157 | The default loader can be overridden using the `loader` property of [import attributes](https://github.com/tc39/proposal-import-attributes) e.g. `import base64 from 'https://placehold.co/10x10.png' with { loader: 'base64' };`. 158 | 159 | ## Development 160 | 161 | Clone and navigate to the repository by running `git clone git@github.com:ashtonmeuser/bookmarklet-platform.git && cd bookmarklet-platform` in your terminal. 162 | 163 | ### Test 164 | 165 | Tests and test coverage metrics can be run using `npm test`. Tests and associated mocks are found in the `test` directory. Coverage artifacts will be located in the `coverage` directory. 166 | 167 | ### Build and Run 168 | 169 | The application can be run in development mode using `npm run dev`. This enables hot reloading and hosts the application locally at [localhost:5000](http://localhost:5000). In development mode, changes to Pug templates will result in the application being recompiled. 170 | 171 | In order to build the application for production, run `npm run build`. This creates all assets required for the static site in the `dist` directory. This also minifies generated JavaScript, HTML, and CSS assets. 172 | 173 | ### Deploy 174 | 175 | Before deploying, the [AWS CLI](https://aws.amazon.com/cli/) must be installed and properly configured. Additionally, an S3 bucket must be configured to host a static website (see [docs](https://docs.aws.amazon.com/AmazonS3/latest/dev/WebsiteHosting.html)). 176 | 177 | Deployment is as simple as pushing the generated static site files to S3 using `aws s3 sync dist s3://[YOUR_BUCKET]`, replacing `[YOUR_BUCKET]` with your S3 bucket's name. 178 | 179 | If hosted behind a CloudFront distribution, an [invalidation](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html) may be required to see the changes immediately. Configuring a CloudFront distribution is beyond the scope of this document. 180 | 181 | Further, a simple routing rule must be applied to catch all requests to bookmarklet URIs. For example, the following simple [AWS Lambda@Edge](https://aws.amazon.com/lambda/edge/) function can be used to map all HTML requests (besides root) to the bookmarklets page. 182 | 183 | ```js 184 | export const handler = async (event, context, callback) => { 185 | const request = event?.Records?.[0]?.cf?.request; 186 | if (request?.uri === '/' || request?.uri === '') return callback(null, request); 187 | if (request?.headers?.accept?.[0]?.value?.includes('text/html')) request.uri = '/bookmarklet.html'; 188 | callback(null, request); 189 | }; 190 | ``` 191 | 192 | ## Forewarning 193 | 194 | Bookmarklets can be used maliciously. Be aware of the function being performed by a bookmarklet before using or saving it. The author denies any responsibility for harm caused by a malicious bookmarklet. 195 | 196 | Note that unless a specific version is linked to (see difference between [unversioned](https://bookmarkl.ink/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea//count.js) and [versioned](https://bookmarkl.ink/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/42b76885db0d1ca517377207d90c23a85a030bf2/count.js)), the function of a bookmarklet may change unexpectedly. 197 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookmarklet-platform", 3 | "version": "0.2.1", 4 | "description": "A platform for distributing bookmarklets.", 5 | "repository": "github:ashtonmeuser/bookmarklet-platform", 6 | "author": "Ashton Meuser ", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "concurrently -k 'parcel watch src/**.pug' 'serve -d -c serve.json'", 10 | "test": "vitest run --environment jsdom --coverage", 11 | "prebuild:app": "rm -rf dist/* .parcel-cache", 12 | "build:app": "parcel build 'src/*.pug' --no-source-maps", 13 | "build:router": "esbuild src/js/router.ts --bundle --format=esm --minify --outfile=index.mjs && zip -m router.zip index.mjs", 14 | "build": "npm run build:app && npm run build:router", 15 | "presync:app": "npm run build:app", 16 | "presync:router": "npm run build:router", 17 | "sync:app": "aws s3 sync dist s3://bookmarkl.ink --delete", 18 | "sync:router": "aws lambda update-function-code --region us-east-1 --function-name bookmarklink --zip-file fileb://router.zip", 19 | "sync": "npm run sync:app && npm run sync:router" 20 | }, 21 | "devDependencies": { 22 | "@parcel/transformer-pug": "^2.13.3", 23 | "@types/aws-lambda": "^8.10.147", 24 | "@vitest/coverage-v8": "^3.0.3", 25 | "buffer": "^6.0.3", 26 | "concurrently": "^9.1.2", 27 | "jsdom": "^26.0.0", 28 | "parcel": "^2.13.3", 29 | "path-browserify": "^1.0.1", 30 | "process": "^0.11.10", 31 | "serve": "^14.2.4", 32 | "vitest": "^3.0.3" 33 | }, 34 | "dependencies": { 35 | "@codemirror/lang-javascript": "^6.2.2", 36 | "@lezer/highlight": "^1.2.1", 37 | "alpinejs": "^3.14.8", 38 | "codemirror": "^6.0.1", 39 | "esbuild-wasm": "^0.24.2", 40 | "uuid": "^11.0.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": "dist", 3 | "rewrites": [ 4 | { 5 | "source": "/:author/:id/**", 6 | "destination": "/bookmarklet.html" 7 | }, 8 | { 9 | "source": "/playground", 10 | "destination": "/bookmarklet.html" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/damion-v10.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashtonmeuser/bookmarklet-platform/1f829210d875725d7339ca797543ce86f2516661/src/assets/damion-v10.woff -------------------------------------------------------------------------------- /src/assets/damion-v10.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashtonmeuser/bookmarklet-platform/1f829210d875725d7339ca797543ce86f2516661/src/assets/damion-v10.woff2 -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashtonmeuser/bookmarklet-platform/1f829210d875725d7339ca797543ce86f2516661/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/bookmarklet.pug: -------------------------------------------------------------------------------- 1 | extends partials/layout 2 | 3 | block head 4 | script(type='module' src='./js/bookmarklet.ts') 5 | 6 | block content 7 | template(x-if='error') 8 | include partials/error.pug 9 | template(x-if='!error && gist') 10 | .contents 11 | include partials/editor.pug 12 | include partials/properties.pug 13 | include partials/variables.pug 14 | include partials/transpilationError.pug 15 | include partials/bookmarkletButton.pug 16 | include partials/copyButton.pug 17 | 18 | block footer 19 | include partials/gistLink.pug 20 | include partials/homeLink.pug 21 | -------------------------------------------------------------------------------- /src/error.pug: -------------------------------------------------------------------------------- 1 | extends partials/layout 2 | 3 | block head 4 | 5 | block content 6 | include partials/error.pug 7 | 8 | block footer 9 | include partials/homeLink.pug 10 | -------------------------------------------------------------------------------- /src/index.pug: -------------------------------------------------------------------------------- 1 | extends partials/layout 2 | 3 | block head 4 | script(type='module' src='./js/home.ts') 5 | 6 | block content 7 | include partials/about.pug 8 | include partials/createForm.pug 9 | include partials/playgroundLink.pug 10 | 11 | block footer 12 | include partials/repoLink.pug 13 | include partials/kofiLink.pug 14 | -------------------------------------------------------------------------------- /src/js/Bundler.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild-wasm'; 2 | 3 | const ESBUILD_INITIALIZE = esbuild.initialize({ wasmURL: 'https://esm.sh/esbuild-wasm@0.24.2/esbuild.wasm' }); // Must be resolved before transpilation 4 | 5 | type Loader = (args: esbuild.OnLoadArgs) => Promise; 6 | type Transformer = (result: esbuild.OnLoadResult) => Promise; 7 | type BundlerOptions = { sourcefile?: string, cdn?: { static?: string, dynamic?: string } }; 8 | export class OutdatedBundleError extends Error {} 9 | 10 | const loader = (loader?: esbuild.Loader, transformer?: Transformer): Loader => (async (args) => { 11 | const response = await globalThis.fetch(args.path); 12 | if (!response.ok) throw new Error(`failed to fetch ${args.path}`); 13 | const contents = new Uint8Array(await response.arrayBuffer()); 14 | const result = { contents, loader: args.with.loader as esbuild.Loader ?? loader }; 15 | return transformer ? transformer(result) : result; 16 | }); 17 | 18 | const transformerCss: Transformer = async (result) => { 19 | return { ...result, contents: (await esbuild.transform(result.contents!, { loader: 'css', minify: true })).code } 20 | }; 21 | 22 | // Simple esbuild plugin to resolve and bundle dependencies 23 | // Static imports are bundled, dynamic imports are not 24 | // Top-level relative static and dynamic imports are resolved relative to custom CDN to deliver gist content 25 | // Dynamic imports require Content-Type: text/javascript which GitHub does not provide 26 | const plugin = (options?: BundlerOptions): esbuild.Plugin => ({ 27 | name: 'bundler-plugin', 28 | setup(build) { 29 | const sourcefile = options?.sourcefile ?? ''; 30 | 31 | build.onResolve({ filter: /^\.?\.?\// }, (args) => { 32 | const url = (cdn?: string): string => new URL(args.path, args.importer === sourcefile ? cdn : args.importer).toString(); 33 | if (args.kind === 'import-statement') return { path: url(options?.cdn?.static), namespace: 'static', sideEffects: false }; 34 | if (args.kind === 'dynamic-import') return { path: url(options?.cdn?.dynamic), external: true }; 35 | }); 36 | 37 | build.onResolve({ filter: /^https?:\/\// }, (args) => { 38 | if (args.kind === 'import-statement') return { path: args.path, namespace: 'static', sideEffects: false }; 39 | if (args.kind === 'dynamic-import') return { path: args.path, external: true }; 40 | }); 41 | 42 | build.onLoad({ filter: /\.m?js$/, namespace: 'static' }, loader('js')); 43 | build.onLoad({ filter: /\.(ts|mts)$/, namespace: 'static' }, loader('ts')); 44 | build.onLoad({ filter: /\.(jsx|tsx)$/, namespace: 'static' }, loader('jsx')); 45 | build.onLoad({ filter: /\.json$/, namespace: 'static' }, loader('json')); 46 | build.onLoad({ filter: /\.css$/, namespace: 'static' }, loader('text', transformerCss)); 47 | build.onLoad({ filter: /\.(png|jpe?g|gif|svg)$/, namespace: 'static' }, loader('dataurl')); 48 | build.onLoad({ filter: /\.(html|txt|md|xml|yml|dat)$/, namespace: 'static' }, loader('text')); 49 | build.onLoad({ filter: /\.(bin|wasm)$/, namespace: 'static' }, loader('binary')); 50 | build.onLoad({ filter: /.*/, namespace: 'static' }, loader('ts')); 51 | }, 52 | }); 53 | 54 | export class Bundler { 55 | readonly config: esbuild.BuildOptions; 56 | private _latest: Symbol | null = null; // Record latest build token 57 | 58 | constructor(options?: BundlerOptions) { 59 | this.config = { 60 | bundle: true, // Bundle dependencies 61 | target: ['esnext'], // Alternatively, es2017 62 | minify: true, // Tree shake, minify 63 | plugins: [plugin(options)], // Resolve imports 64 | write: false, // Prevents tests writing to FS 65 | stdin: { contents: '', loader: 'ts', sourcefile: options?.sourcefile }, // Load from in-memory buffer instead of FS 66 | logLevel: 'silent', // Prevent console logging errors 67 | }; 68 | } 69 | 70 | async build(code: string, define?: { [key: string]: string }): Promise { 71 | await ESBUILD_INITIALIZE; // Ensure esbuild is initialized 72 | this.config.define = define; 73 | this.config.stdin!.contents = code; 74 | const token = Symbol(); 75 | this._latest = token; 76 | try { 77 | const result = await esbuild.build(this.config); 78 | if (this._latest != token) throw new OutdatedBundleError(); 79 | return result.outputFiles![0].text; 80 | } catch(e) { 81 | if (this._latest != token) throw new OutdatedBundleError(); 82 | throw e; 83 | } 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /src/js/Gist.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | import BookmarkletError from './error'; 3 | import { Bundler, OutdatedBundleError } from './Bundler'; 4 | 5 | enum VariableType { 6 | TEXT = 'text', 7 | PASSWORD = 'password', 8 | NUMBER = 'number', 9 | BOOLEAN = 'boolean', 10 | AUTHOR = 'author', 11 | ID = 'id', 12 | UUID = 'uuid', 13 | }; 14 | 15 | type Variable = { type: VariableType, value: string | number | boolean | null }; 16 | type VariableMap = { [key: string]: Variable }; 17 | 18 | async function fetch(url: string): Promise { 19 | try { 20 | const response = await globalThis.fetch(url); 21 | if (!response.ok) throw new BookmarkletError(response.status, 'failed to fetch javascript code'); 22 | return response.text(); 23 | } catch(e) { 24 | throw e instanceof BookmarkletError ? e : new BookmarkletError(500, 'failed to fetch javascript code'); 25 | } 26 | } 27 | 28 | function getVariableType(type?: string): VariableType { 29 | if (!type) return VariableType.TEXT; 30 | const isVariableType = (value: string): value is VariableType => Object.values(VariableType).includes(value as VariableType); 31 | const lower = type.toLowerCase(); 32 | return isVariableType(lower) ? lower : VariableType.TEXT 33 | } 34 | 35 | function extractProperty(code: string, property: string): string | undefined { 36 | const r = new RegExp(`^[\\s\\t]*//[\\s\\t]*bookmarklet[-_]${property}[\\s\\t]*[:=][\\s\\t]*(.+)`, 'im'); 37 | const matches = code.match(r); 38 | return matches?.[1]; 39 | } 40 | 41 | function extractVariables(code: string): VariableMap { 42 | const matches = code.matchAll(/\/\/[\s\t]*bookmarklet[-_]var(?:\((\w+)\))?[\s\t]*[:=][\s\t]*([a-z_$][\w$]*)[\s\t]*$/gim); 43 | return Array.from(matches).reduce((acc: VariableMap, match: RegExpExecArray): VariableMap => { 44 | const key = match[2]; 45 | const type = getVariableType(match[1]); 46 | if (!(key in acc)) { 47 | acc[key] = { type, value: null }; 48 | } 49 | return acc; 50 | }, {}); 51 | } 52 | 53 | function defineVariables(variables: VariableMap): { [key: string]: string } { 54 | return Object.fromEntries(Object.entries(variables).map(([key, variable]) => { 55 | return [key, JSON.stringify(variable.value)]; 56 | })); 57 | } 58 | 59 | function replaceVariables(code: string): string { 60 | return code.replace(/^.*\/\/[\s\t]*bookmarklet[-_]var(?:\((\w+)\))?[\s\t]*[:=][\s\t]*([a-z_$][\w$]*)[\s\t]*$/gim, ''); 61 | } 62 | 63 | export default class Gist { 64 | readonly author: string; 65 | readonly id: string; 66 | readonly version: string | undefined; 67 | readonly file: string | undefined; 68 | readonly path: string; 69 | readonly url: string; 70 | readonly banner: string; 71 | readonly bundler: Bundler; 72 | title: string = 'bookmarklet'; 73 | about: string | undefined; 74 | href: string | null = null; 75 | error: Error | undefined; 76 | private _variables: VariableMap = {}; 77 | private _code: string | undefined; 78 | 79 | constructor(author: string, id: string, version?: string, file?: string) { 80 | this.author = author; 81 | this.id = id; 82 | this.version = version; 83 | this.file = file; 84 | this.path = `${this.author}/${this.id}/raw${this.version ? `/${this.version}` : ''}/${this.file ?? ''}`; 85 | this.url = `https://gist.github.com/${this.author}/${this.id}${this.version ? `/${this.version}` : ''}`; 86 | this.banner = `/*https://bookmarkl.ink/${this.author}/${this.id}${this.version ? `/${this.version}` : ''}${this.file ? `/${this.file}` : ''}*/`; 87 | this.bundler = new Bundler({ sourcefile: 'bookmarklet', cdn: { static: `https://gist.githubusercontent.com/${this.path}`, dynamic: `https://cdn.bookmarkl.ink/${this.path}` } }) 88 | } 89 | 90 | get size(): string { 91 | if (!this.href) return '0 B'; 92 | const suffixes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; 93 | const size = new Blob([this.href]).size; 94 | const i = Math.floor(Math.log(size) / Math.log(1024)); 95 | return `${(size / 1024 ** i).toFixed(i > 0 ? 1 : 0)} ${suffixes[i]}`; 96 | } 97 | 98 | get code(): string | undefined { 99 | return this._code; 100 | } 101 | 102 | set code(code: string) { 103 | if (this._code === code) return; // No action needed 104 | this._code = code; 105 | this.title = extractProperty(code, 'title') || 'bookmarklet'; 106 | this.about = extractProperty(code, 'about'); 107 | this._syncVariables(); 108 | this.transpile(); // Kick off transpilation when code changes 109 | } 110 | 111 | get variables(): VariableMap { 112 | return Object.fromEntries(Object.entries(this._variables).filter(([_, variable]) => ["text", "number", "password", "boolean"].includes(variable.type))); 113 | } 114 | 115 | async load(): Promise { 116 | this.code = await fetch(`https://gist.githubusercontent.com/${this.path}`); 117 | } 118 | 119 | async transpile(): Promise { 120 | if (this.code === undefined) return; // Code has not yet been fetched 121 | this.error = undefined; 122 | try { 123 | const result = await this.bundler.build(replaceVariables(this.code), defineVariables(this._variables)); 124 | this.href = `javascript:${this.banner}${encodeURIComponent(result)}`; 125 | } catch(e) { 126 | if (e instanceof OutdatedBundleError) return; 127 | this.href = null; 128 | this.error = e; 129 | } 130 | } 131 | 132 | private _syncVariables(): void { 133 | if (this.code === undefined) return; 134 | 135 | const update = extractVariables(this.code); 136 | 137 | for (const key of Object.keys(this._variables)) { 138 | if (!(key in update)) delete this._variables[key]; // Remove variable 139 | } 140 | 141 | for (const [key, variable] of Object.entries(update)) { 142 | if (this._variables[key]?.type === variable.type) continue; // Variable exists 143 | 144 | // Apply non-null defaults 145 | if (variable.type === VariableType.BOOLEAN) variable.value = false; 146 | else if (variable.type === VariableType.AUTHOR) variable.value = this.author; 147 | else if (variable.type === VariableType.ID) variable.value = this.id; 148 | else if (variable.type === VariableType.UUID) variable.value = uuid(); 149 | 150 | // Insert or overwrite variable proxy 151 | this._variables[key] = new Proxy(variable, { 152 | set: (target, key: string, value: string | number | boolean) => { 153 | if (key !== 'value') return false; 154 | if (target.type === VariableType.NUMBER) target.value = value === '' ? null : Number.isNaN(Number(value)) ? null : Number(value); 155 | else if (target.type === VariableType.BOOLEAN) target.value = Boolean(value); 156 | else if (target.type == VariableType.TEXT || target.type == VariableType.PASSWORD) target.value = String(value); 157 | this.transpile(); // Kick off transpilation when variables change 158 | return true; 159 | }, 160 | }); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/js/Playground.ts: -------------------------------------------------------------------------------- 1 | import Gist from './Gist'; 2 | 3 | export default class Playground extends Gist { 4 | readonly url: string; 5 | readonly banner: string; 6 | 7 | constructor() { 8 | super('', ''); 9 | this.title = 'playground'; 10 | this.url = ''; 11 | this.banner = '/*https://bookmarkl.ink*/'; 12 | } 13 | 14 | async load(): Promise { 15 | this.code = `// bookmarklet-title: playground 16 | // bookmarklet-about: Build your own bookmarklet. Once you're happy, save the code to a GitHub gist (http://gist.github.com). 17 | 18 | // You can include variables to be bundled into your bookmarklet (see https://github.com/ashtonmeuser/bookmarklet-platform#including-variables). 19 | // bookmarklet-var: name 20 | 21 | // You can even bundle external libraries! 22 | // import * as cheerio from 'https://esm.sh/cheerio'; 23 | // console.log(cheerio.load('
    ...
').html()); 24 | 25 | const message = \`Hello, \${name || 'World'}!\`; 26 | console.log(message); 27 | alert(message);`; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/js/bookmarklet.ts: -------------------------------------------------------------------------------- 1 | import Alpine from 'alpinejs'; 2 | import Gist from './Gist'; 3 | import Playground from './Playground'; 4 | import { parseBookmarkletPath } from './parsePath'; 5 | import BookmarkletError from './error'; 6 | import insertEditor from './editor'; 7 | 8 | type Data = { 9 | gist: Gist | null; 10 | error: BookmarkletError | null; 11 | edit: boolean; 12 | copy: () => void; 13 | init: () => void; 14 | } 15 | 16 | const data = (): Data => ({ 17 | gist: null, 18 | error: null, 19 | edit: globalThis.location.hash === '#edit', 20 | async copy() { 21 | if (this.gist?.href) return navigator.clipboard.writeText(this.gist.href); 22 | }, 23 | async init() { 24 | try { 25 | if (/^\/playground\/?$/.test(globalThis.location.pathname)) { 26 | this.edit = true; 27 | this.gist = new Playground(); 28 | } else { 29 | const props = parseBookmarkletPath(globalThis.location.pathname); 30 | this.gist = new Gist(props.author, props.id, props.version, props.file); 31 | } 32 | await this.gist.load(); 33 | document.title = `bookmarkl.ink · ${this.gist.title}`; 34 | insertEditor(this.$refs.editor, this.gist.code, (code: string) => { this.gist.code = code; }); 35 | } catch (e) { 36 | if (e instanceof BookmarkletError) this.error = e; 37 | else this.error = new BookmarkletError(500, 'unexpected error'); 38 | } 39 | }, 40 | }); 41 | 42 | globalThis.data = data; 43 | export default data; 44 | 45 | Alpine.start(); 46 | -------------------------------------------------------------------------------- /src/js/editor.ts: -------------------------------------------------------------------------------- 1 | import { minimalSetup, EditorView } from 'codemirror'; 2 | import { lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'; 3 | import { highlightSelectionMatches } from '@codemirror/search'; 4 | import { javascript } from '@codemirror/lang-javascript'; 5 | import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' 6 | import { tags as t } from '@lezer/highlight'; 7 | 8 | const theme = { 9 | '&': { 'backgroundColor': '#ffffff', 'display': 'inline-block !important', 'width': '100%', 'max-height': '300px', 'overflow-y': 'scroll', 'text-align': 'initial', 'font-size': '0.9rem' }, 10 | '&.cm-focused': { 'outline': 'none' }, 11 | '.cm-gutters': { 'backgroundColor': '#ffffff', 'color': '#ccc', 'border': 'none' }, 12 | '.cm-selectionBackground, .cm-editor ::selection': { 'backgroundColor': '#add6ff !important' }, 13 | '.cm-selectionMatch': { 'background-color': '#add6ff66' }, 14 | '.cm-activeLine, .cm-activeLineGutter': { 'background-color': '#0000000a' } 15 | }; 16 | const highlight = HighlightStyle.define([ 17 | { tag: [t.keyword, t.operatorKeyword, t.modifier, t.color, t.constant(t.name), t.standard(t.name), t.standard(t.tagName), t.special(t.brace), t.atom, t.bool, t.special(t.variableName)], color: '#0000ff' }, 18 | { tag: [t.moduleKeyword, t.controlKeyword], color: '#af00db' }, 19 | { tag: [t.name, t.deleted, t.character, t.macroName, t.propertyName, t.variableName, t.labelName, t.definition(t.name)], color: '#0070c1' }, 20 | { tag: t.heading, fontWeight: 'bold', color: '#0070c1' }, 21 | { tag: [t.typeName, t.className, t.tagName, t.number, t.changed, t.annotation, t.self, t.namespace], color: '#267f99' }, 22 | { tag: [t.function(t.variableName), t.function(t.propertyName)], color: '#795e26' }, 23 | { tag: [t.number], color: '#098658' }, 24 | { tag: [t.operator, t.punctuation, t.separator, t.url, t.escape, t.regexp], color: '#383a42' }, 25 | { tag: [t.regexp], color: '#af00db' }, 26 | { tag: [t.special(t.string), t.processingInstruction, t.string, t.inserted], color: '#a31515' }, 27 | { tag: [t.angleBracket], color: '#383a42' }, 28 | { tag: t.strong, fontWeight: 'bold' }, 29 | { tag: t.emphasis, fontStyle: 'italic' }, 30 | { tag: t.strikethrough, textDecoration: 'line-through' }, 31 | { tag: [t.meta, t.comment], color: '#008000' }, 32 | { tag: t.link, color: '#4078f2', textDecoration: 'underline' }, 33 | { tag: t.invalid, color: '#e45649' }, 34 | ]); 35 | 36 | export default (element: Element, code?: string, callback?: (code: string) => void): EditorView => { 37 | const extension = [ 38 | EditorView.theme(theme), 39 | syntaxHighlighting(highlight), 40 | EditorView.updateListener.of((e) => { 41 | if (!e.docChanged) return; 42 | callback?.(e.state.doc.toString()); 43 | }), 44 | ]; 45 | return new EditorView({ 46 | doc: code, 47 | extensions: [minimalSetup, lineNumbers(), highlightActiveLine(), highlightActiveLineGutter(), highlightSelectionMatches(), javascript({ typescript: true }), extension], 48 | parent: element, 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/js/error.ts: -------------------------------------------------------------------------------- 1 | export default class BookmarkletError extends Error { 2 | code: number; 3 | 4 | constructor(code: number, message: string) { 5 | super(message); 6 | this.code = code; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/js/home.ts: -------------------------------------------------------------------------------- 1 | import Alpine from 'alpinejs'; 2 | import { parseGistPath } from './parsePath'; 3 | 4 | type Data = { 5 | gistUrl: string; 6 | bookmarkletUrl: string | null; 7 | valid: boolean; 8 | init: () => void; 9 | } 10 | 11 | const data = (): Data => ({ 12 | init() {}, 13 | gistUrl: '', 14 | get bookmarkletUrl() { 15 | try { 16 | const props = parseGistPath(this.gistUrl); 17 | return `/${props.author}/${props.id}${props.version ? `/${props.version}` : ''}${props.file ? `/${props.file}` : ''}`; 18 | } catch (error) { 19 | return null; 20 | } 21 | }, 22 | get valid() { 23 | return this.bookmarkletUrl !== null; 24 | }, 25 | }); 26 | 27 | globalThis.data = data; 28 | export default data; 29 | 30 | Alpine.start(); 31 | -------------------------------------------------------------------------------- /src/js/parsePath.ts: -------------------------------------------------------------------------------- 1 | import BookmarkletError from './error'; 2 | 3 | type UrlProperties = { 4 | author: string; 5 | id: string; 6 | version?: string; 7 | file?: string; 8 | } 9 | 10 | const parsePath = (urlString: string, base: string, pathPattern: RegExp, hostPattern?: RegExp): UrlProperties => { 11 | const url = new URL(urlString, base); 12 | if (hostPattern && !hostPattern.test(url.hostname)) throw new BookmarkletError(400, 'invalid hostname'); 13 | const matches = url.pathname.match(pathPattern); 14 | if (!matches) throw new BookmarkletError(400, 'invalid path'); 15 | const [author, id, version, file] = matches.slice(1); 16 | return { 17 | author, 18 | id, 19 | version: version || undefined, 20 | file: file || undefined, 21 | }; 22 | }; 23 | 24 | export const parseGistPath = (urlString: string): UrlProperties => { 25 | // Should match raw and GitHub URLs; see https://regexr.com/86bok 26 | const pathPattern = /^\/(\w+(?:[\w-]*\w)?)\/([a-f0-9]{32}|[a-f0-9]{20}|\d{7})(?:\/raw)?(?:\/([a-f0-9]{40})?)?(?:\/(.+?)?)?\/?$/; 27 | const hostPattern = /^gist\.github(?:usercontent)?\.com$/; 28 | return parsePath(urlString, 'https://gist.github.com', pathPattern, hostPattern); 29 | }; 30 | 31 | export const parseBookmarkletPath = (urlString: string): UrlProperties => { 32 | const pathPattern = /^\/(\w+(?:[\w-]*\w)?)\/([a-f0-9]{32}|[a-f0-9]{20}|\d{7})(?:\/([a-f0-9]{40})?)?(?:\/(.+?)?)?\/?$/; 33 | return parsePath(urlString, 'https://bookmarkl.ink', pathPattern); 34 | }; 35 | -------------------------------------------------------------------------------- /src/js/router.ts: -------------------------------------------------------------------------------- 1 | import { Handler, CloudFrontRequestEvent } from 'aws-lambda'; 2 | 3 | export const handler: Handler = async (event, _, callback) => { 4 | const request = event?.Records?.[0]?.cf?.request; 5 | if (request?.uri === '/' || request?.uri === '') return callback(null, request); 6 | if (request?.headers?.accept?.[0]?.value?.includes('text/html')) request.uri = '/bookmarklet.html'; 7 | callback(null, request); 8 | }; 9 | -------------------------------------------------------------------------------- /src/partials/about.pug: -------------------------------------------------------------------------------- 1 | .row 2 | p 3 | | bookmarkl.ink can be used to share simple javascript gists, executable 4 | | in the browser and saved as bookmarklets, like a 5 | a(:href='"/ashtonmeuser/21427841853c9f2292c8f7d7af0079ea/qr.js"') QR code creator 6 | | , a 7 | a(:href='"/ashtonmeuser/b941181ffb8f8ceee08859f9ea9cc908"') reader view 8 | | , or 9 | a(:href='"/ashtonmeuser/04710d3befc446e849108a58755163ea"') the entirety of DOOM 10 | | . 11 | 12 | p 13 | | to generate your own bookmarklet, write some javascript, save it as a 14 | a(target='_blank' href='https://gist.github.com') github gist 15 | | , copy the url, paste it below, and hit create. 16 | 17 | p 18 | | you can assign a title and description to your bookmarklet by including 19 | span.code //bookmarklet_title: <your title> 20 | | and 21 | span.code //bookmarklet_about: <your details> 22 | | in your code, respectively. 23 | 24 | p 25 | | to learn more, see the 26 | a(target='_blank' href='https://github.com/ashtonmeuser/bookmarklet-platform') project repo 27 | | . 28 | -------------------------------------------------------------------------------- /src/partials/bookmarkletButton.pug: -------------------------------------------------------------------------------- 1 | .row.space-top 2 | a.button(:href='gist.href') 3 | .button-title(x-text='gist.title') 4 | #div-bookmarklet-subtitle 5 | -------------------------------------------------------------------------------- /src/partials/copyButton.pug: -------------------------------------------------------------------------------- 1 | .row(x-show='gist?.href') 2 | button(x-on:click='copy()') copy bookmarklet code 3 | -------------------------------------------------------------------------------- /src/partials/createForm.pug: -------------------------------------------------------------------------------- 1 | .row.space-top 2 | input(type='text' placeholder='gist url' x-model='gistUrl') 3 | 4 | .row 5 | a.button(:href='bookmarkletUrl') 6 | .button-title create 7 | -------------------------------------------------------------------------------- /src/partials/editor.pug: -------------------------------------------------------------------------------- 1 | .row(x-show='gist.code !== undefined') 2 | button(x-on:click='edit = !edit' x-text='edit ? "hide editor" : "show editor"') 3 | .editor.space-top(x-ref='editor' x-show='edit') 4 | -------------------------------------------------------------------------------- /src/partials/error.pug: -------------------------------------------------------------------------------- 1 | .error 2 | .error-code(x-text='error?.code ?? 500') 404 3 | span(x-text='error?.code ? error.message : "unexpected error"') not found 4 | -------------------------------------------------------------------------------- /src/partials/gistLink.pug: -------------------------------------------------------------------------------- 1 | .row(x-show='gist?.url') 2 | a(target='_blank' :href='gist?.url') view gist 3 | -------------------------------------------------------------------------------- /src/partials/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | .row 3 | a(href='/') 4 | h1 bookmarkl.ink 5 | .row 6 | h2 the bookmarklet build system 7 | -------------------------------------------------------------------------------- /src/partials/homeLink.pug: -------------------------------------------------------------------------------- 1 | .row 2 | a(href='/') generate bookmarklet 3 | -------------------------------------------------------------------------------- /src/partials/kofiLink.pug: -------------------------------------------------------------------------------- 1 | .row 2 | a(href='https://ko-fi.com/ashtonmeuser' target='_blank') buy me a coffee 3 | -------------------------------------------------------------------------------- /src/partials/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | head 4 | meta(charset='utf-8') 5 | meta(name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no') 6 | title bookmarkl.ink 7 | link(rel='shortcut icon' href='./assets/favicon.ico') 8 | link(rel='stylesheet' href='./style.css') 9 | block head 10 | body 11 | main(x-data='data()') 12 | include header.pug 13 | section 14 | block content 15 | footer 16 | block footer 17 | -------------------------------------------------------------------------------- /src/partials/playgroundLink.pug: -------------------------------------------------------------------------------- 1 | .row 2 | | or, open a 3 | a(href='/playground') playground 4 | | . 5 | -------------------------------------------------------------------------------- /src/partials/properties.pug: -------------------------------------------------------------------------------- 1 | .row 2 | h3 properties 3 | table 4 | template(x-for='key in ["author", "id", "version", "file", "title", "about", "size"]') 5 | tr(x-show='gist[key]') 6 | td(x-text='key') 7 | td(x-text='gist[key]') 8 | -------------------------------------------------------------------------------- /src/partials/repoLink.pug: -------------------------------------------------------------------------------- 1 | .row 2 | a(href='http://github.com/ashtonmeuser/bookmarklet-platform' target='_blank') view on github 3 | -------------------------------------------------------------------------------- /src/partials/transpilationError.pug: -------------------------------------------------------------------------------- 1 | template(x-if='gist.error') 2 | .space-top 3 | h3 transpilation error 4 | pre(x-text='gist.error.message') 5 | -------------------------------------------------------------------------------- /src/partials/variables.pug: -------------------------------------------------------------------------------- 1 | .row(x-show='Object.keys(gist.variables).length') 2 | h3 variables 3 | table 4 | template(x-for='[key, variable] in Object.entries(gist.variables)') 5 | tr 6 | td.v-center 7 | label(:for='`variable-${key}`' x-text='key') 8 | td 9 | template(x-if='variable.type === "boolean"') 10 | label 11 | input(:id='`variable-${key}`' type='checkbox' x-model='variable.value') 12 | div 13 | template(x-if='variable.type !== "boolean"') 14 | input(:type='variable.type' :id='`variable-${key}`' x-model.unintrusive.debounce.150ms='variable.value' autocomplete="off") 15 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | :root { 3 | --max-width: 530px; 4 | --body-background-color: #F5F5F5; 5 | --button-color: #FFF; 6 | --error-color: #EA4848; 7 | --button-background-color: #5F9EA0; 8 | --button-hover-background-color: #7EB1B3; 9 | --disabled-button-color: #BBB; 10 | --link-color: #000; 11 | --link-hover-color: #666; 12 | --code-background-color: #DDD; 13 | --transition-duration: 0.2s; 14 | } 15 | 16 | /* Fonts */ 17 | @font-face { 18 | font-family: 'Damion'; 19 | font-style: normal; 20 | font-weight: 400; 21 | src: local(''), url('assets/damion-v10.woff2') format('woff2'), url('assets/damion-v10.woff') format('woff'); 22 | } 23 | 24 | /* Layout */ 25 | html { 26 | box-sizing: border-box; 27 | * { 28 | box-sizing: inherit; 29 | } 30 | } 31 | body { 32 | font-family: Courier New; 33 | font-size: 16px; 34 | padding: 0; 35 | margin: 0; 36 | min-height: 100vh; 37 | text-align: center; 38 | background-color: var(--body-background-color); 39 | } 40 | main { 41 | display: flex; 42 | flex-direction: column; 43 | align-items: center; 44 | justify-content: center; 45 | min-height: 100vh; 46 | padding: 20px; 47 | & > * { 48 | width: 100%; 49 | max-width: var(--max-width); 50 | } 51 | } 52 | p { 53 | text-align: justify; 54 | margin: 0 0 10px 0; 55 | &:last-of-type { 56 | margin-bottom: 0; 57 | } 58 | } 59 | .contents { 60 | display: contents; 61 | } 62 | .row { 63 | margin-bottom: 10px; 64 | &:last-of-type { 65 | margin-bottom: 0; 66 | } 67 | } 68 | .space-top { 69 | padding-top: 10px; 70 | } 71 | 72 | /* Buttons, links */ 73 | .button-title { 74 | font-size: 1.5em; 75 | white-space: nowrap; 76 | text-overflow: ellipsis; 77 | overflow: hidden; 78 | } 79 | a.button { /* Anchors styled as buttons (required for bookmarklet) */ 80 | transition: background-color var(--transition-duration) ease; 81 | background-color: var(--button-background-color); 82 | border-radius: 10px; 83 | color: var(--button-color); 84 | padding: 20px; 85 | text-decoration: none; 86 | display: block; 87 | white-space: nowrap; 88 | text-overflow: ellipsis; 89 | overflow: hidden; 90 | &:hover { 91 | @media (any-hover: hover) { 92 | background-color: var(--button-hover-background-color); 93 | } 94 | } 95 | &:not([href]) { 96 | background-color: var(--disabled-button-color); 97 | &:hover { 98 | cursor: not-allowed; 99 | } 100 | } 101 | } 102 | a:not(.button) { /* Typical anchors */ 103 | transition: color var(--transition-duration) ease; 104 | color: var(--link-color); 105 | text-decoration: underline; 106 | &:hover { 107 | color: var(--link-hover-color); 108 | } 109 | } 110 | button { /* Buttons styled as anchors (toggle code editor) */ 111 | background: none !important; 112 | border: none; 113 | padding: 0 !important; 114 | font-family: inherit; 115 | font-size: inherit; 116 | text-decoration: underline; 117 | cursor: pointer; 118 | color: inherit; 119 | &:hover { 120 | color: var(--link-hover-color); 121 | } 122 | } 123 | 124 | /* Input */ 125 | input { 126 | font-family: sans-serif; 127 | text-align: center; 128 | font-size: 1em; 129 | border-radius: 10px; 130 | border: 0; 131 | width: 100%; 132 | padding: 10px; 133 | } 134 | label:has(input[type='checkbox']) { 135 | font-family: sans-serif; 136 | cursor: pointer; 137 | overflow: hidden; 138 | position: relative; 139 | display: inline-flex; 140 | justify-content: space-evenly; 141 | width: 100%; 142 | font-size: 1em; 143 | border-radius: 10px; 144 | border: 0; 145 | width: 100%; 146 | &:focus-within { 147 | outline: -webkit-focus-ring-color auto 1px; 148 | } 149 | input { 150 | position: absolute; 151 | opacity: 0; 152 | width: 0; 153 | height: 0; 154 | &:checked + div { 155 | left: 50%; 156 | } 157 | } 158 | div { 159 | position: absolute; 160 | z-index: -1; 161 | backdrop-filter: blur(2px); 162 | -webkit-backdrop-filter: blur(2px) !important; /* Forces autoprefixer to include both */ 163 | left: 0; 164 | width: 50%; 165 | height: 100%; 166 | background-color: #0001; 167 | } 168 | &::before, &::after { 169 | position: relative; 170 | content: 'true'; 171 | flex: 1; 172 | z-index: -2; 173 | background-color: white; 174 | padding: 10px; 175 | text-align: center; 176 | } 177 | &::after { 178 | content: 'false'; 179 | } 180 | &:has(input:checked)::after, &:has(input:not(:checked))::before { 181 | color: #bbb; 182 | } 183 | } 184 | 185 | /* Header, footer */ 186 | header { 187 | margin-bottom: 30px; 188 | h1 { 189 | @media only screen and (max-width: 420px) { 190 | font-size: 3em; 191 | } 192 | font-family: 'Damion', cursive; 193 | font-size: 4em; 194 | line-height: 0.8; 195 | font-weight: bold; 196 | margin: 0; 197 | } 198 | h2 { 199 | font-weight: normal; 200 | font-size: 1.5em; 201 | margin: 0; 202 | line-height: 1em; 203 | } 204 | a { 205 | text-decoration: none !important; 206 | } 207 | } 208 | footer { 209 | margin-top: 30px; 210 | } 211 | 212 | /* Bookmarklet */ 213 | #div-bookmarklet-subtitle:after { /* Use pseudo-elements to avoid anchor title including subtitle */ 214 | content: 'drag to bookmarks to save' 215 | } 216 | h3 { 217 | font-size: 1em; 218 | margin-block-start: 0; 219 | margin-block-end: 0; 220 | } 221 | table { 222 | width: 100%; 223 | tr { 224 | td { 225 | padding: 0 0 10px 0; 226 | vertical-align: top; 227 | text-align: right; 228 | &:nth-child(1) { 229 | max-width: 10em; 230 | overflow: hidden; 231 | text-overflow: ellipsis; 232 | } 233 | &:nth-child(2) { 234 | text-align: justify; 235 | word-break: break-word; 236 | padding-left: 10px; 237 | width: 100%; 238 | } 239 | } 240 | &:last-of-type { 241 | td { 242 | padding-bottom: 0; 243 | } 244 | } 245 | } 246 | } 247 | 248 | /* Errors */ 249 | pre { 250 | text-align: justify; 251 | margin: 0; 252 | font-family: inherit; 253 | font-size: 0.9em; 254 | background-color: #ffaeae; 255 | padding: 1em; 256 | border-radius: 10px; 257 | overflow-x: scroll; 258 | } 259 | .error{ 260 | margin: 3em 0; 261 | .error-code { 262 | font-size: 10em; 263 | margin-bottom: 0; 264 | line-height: 0.8em; 265 | } 266 | } 267 | 268 | /* Text styles */ 269 | .code { 270 | background-color: var(--code-background-color); 271 | } 272 | .v-center { 273 | vertical-align: middle; 274 | } 275 | -------------------------------------------------------------------------------- /test/Bundler.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | import { mockResponse } from './__mock__/fetch'; 3 | import './__mock__/TextEncoder'; 4 | import './__mock__/esbuild'; 5 | import { Bundler, OutdatedBundleError } from '../src/js/Bundler'; 6 | 7 | it('should create bundler', () => { 8 | const bundler = new Bundler(); 9 | expect(bundler).toBeInstanceOf(Bundler); 10 | }); 11 | 12 | it('should bundle code', async () => { 13 | const bundler = new Bundler(); 14 | const code = 'const someVarName = "test";\nconsole.log(someVarName);'; 15 | const result = await bundler.build(code); 16 | expect(result).toBe('(()=>{var o="test";console.log(o);})();\n'); 17 | }); 18 | 19 | it('should resolve remote static import', async () => { 20 | const bundler = new Bundler(); 21 | mockResponse.body = 'export default console.log("hello");'; 22 | const code = 'import log from "https://test.co/test.js";\nlog();'; 23 | const result = await bundler.build(code); 24 | expect(result).toBe('(()=>{var o=console.log("hello");o();})();\n'); 25 | }); 26 | 27 | it('should resolve relative static import', async () => { 28 | const bundler = new Bundler({ cdn: { static: 'https://cdn.co' } }); 29 | mockResponse.body = 'export default console.log("hello");'; 30 | const code = 'import log from "./test.js";\nlog();'; 31 | const result = await bundler.build(code); 32 | expect(globalThis.fetch).toHaveBeenCalledWith('https://cdn.co/test.js'); 33 | expect(result).toBe('(()=>{var o=console.log("hello");o();})();\n'); 34 | }); 35 | 36 | it('should resolve nested static import', async () => { 37 | const bundler = new Bundler(); 38 | mockResponse.body = 'import "./test.js";'; 39 | const code = 'import "https://test.co/test.js";'; 40 | const result = await bundler.build(code); 41 | expect(result).toBe('(()=>{})();\n'); 42 | }); 43 | 44 | it('should resolve remote dynamic import', async () => { 45 | const bundler = new Bundler(); 46 | const code = '(async () => { await import("https://test.co/test.js"); })();'; 47 | const result = await bundler.build(code); 48 | expect(result).toContain('await import("https://test.co/test.js")'); 49 | }); 50 | 51 | it('should resolve relative dynamic import', async () => { 52 | const bundler = new Bundler({ cdn: { dynamic: 'https://cdn.co' } }); 53 | const code = '(async () => { await import("./test.js"); })();'; 54 | const result = await bundler.build(code); 55 | expect(result).toContain('await import("https://cdn.co/test.js")'); 56 | }); 57 | 58 | it('should discard module side effects', async () => { 59 | const bundler = new Bundler(); 60 | mockResponse.body = 'console.log("hello");'; 61 | const code = 'import "https://test.co/test.js";'; 62 | const result = await bundler.build(code); 63 | expect(result).toBe('(()=>{})();\n'); 64 | }); 65 | 66 | it('should bundle minified CSS', async () => { 67 | const bundler = new Bundler(); 68 | mockResponse.body = 'body {\n color: red;\n}'; 69 | const code = 'import css from "https://cdn.co/test.css";\nconsole.log(css);'; 70 | const result = await bundler.build(code); 71 | expect(result).toContain('body{color:red}'); 72 | }); 73 | 74 | it('should bundle images', async () => { 75 | const bundler = new Bundler(); 76 | const code = 'import png from "https://cdn.co/test.png";\nimport jpg from "https://cdn.co/test.jpg";\nimport gif from "https://cdn.co/test.gif";\nimport svg from "https://cdn.co/test.svg";\nconsole.log(png, jpg, gif, svg);'; 77 | const result = await bundler.build(code); 78 | expect(result).toContain('data:image/png'); 79 | expect(result).toContain('data:image/jpeg'); 80 | expect(result).toContain('data:image/gif'); 81 | expect(result).toContain('data:image/svg+xml'); 82 | }); 83 | 84 | it('should bundle JSON', async () => { 85 | const bundler = new Bundler(); 86 | mockResponse.body = '{ "array": [0, 1, 2, 3], "string": "test" }'; 87 | const code = 'import json from "https://cdn.co/test.json";\nconsole.log(json);'; 88 | const result = await bundler.build(code); 89 | expect(result).toContain('array:[0,1,2,3],string:"test"'); 90 | }); 91 | 92 | it('should bundle text', async () => { 93 | const bundler = new Bundler(); 94 | mockResponse.body = '
test
'; 95 | const code = 'import html from "https://cdn.co/test.html";\nconsole.log(html);'; 96 | const result = await bundler.build(code); 97 | expect(result).toContain('"
test
"'); 98 | }); 99 | 100 | it('should bundle binary', async () => { 101 | const bundler = new Bundler(); 102 | const code = 'import bin from "https://cdn.co/test.bin";\nconsole.log(bin);'; 103 | const result = await bundler.build(code); 104 | expect(result).toMatch(/Uint8Array\(\d+\)/); 105 | }); 106 | 107 | it('should bundle using custom loader', async () => { 108 | const bundler = new Bundler(); 109 | const data = 'data'; 110 | mockResponse.body = data; 111 | const code = 'import data from "https://cdn.co/test.png" with { loader: "base64" };\nconsole.log(data);'; 112 | const result = await bundler.build(code); 113 | expect(result).toContain(btoa(data)); 114 | }); 115 | 116 | it('should fail to resolve remote static import', async () => { 117 | const bundler = new Bundler(); 118 | mockResponse.code = 500; 119 | const code = 'import "https://cdn.co/test.js";'; 120 | const promise = bundler.build(code); 121 | await expect(promise).rejects.toThrow(); 122 | }); 123 | 124 | it('should fail outdated build', async () => { 125 | const bundler = new Bundler(); 126 | const code = ''; 127 | const promise = bundler.build(code); 128 | bundler.build(code); 129 | await expect(promise).rejects.toBeInstanceOf(OutdatedBundleError); 130 | }); 131 | -------------------------------------------------------------------------------- /test/Gist.test.ts: -------------------------------------------------------------------------------- 1 | import { vi, it, expect } from 'vitest'; 2 | import { mockResponse } from './__mock__/fetch'; 3 | import './__mock__/TextEncoder'; 4 | import './__mock__/esbuild'; 5 | import Gist from '../src/js/Gist'; 6 | 7 | it('should create gist', () => { 8 | const author = 'testAuthor'; 9 | const id = 'testId'; 10 | const gist = new Gist(author, id); 11 | expect(gist).toBeInstanceOf(Gist); 12 | expect(gist.author).toBe(author); 13 | expect(gist.id).toBe(id); 14 | expect(gist.url).toBe('https://gist.github.com/testAuthor/testId'); 15 | }); 16 | 17 | it('should create gist with optional properties', () => { 18 | const author = 'testAuthor'; 19 | const id = 'testId'; 20 | const version = '0123456789012345678901234567890123456789'; 21 | const file = 'test.js'; 22 | const gist = new Gist(author, id, version, file); 23 | expect(gist.version).toBe(version); 24 | expect(gist.file).toBe(file); 25 | expect(gist.url).toBe('https://gist.github.com/testAuthor/testId/0123456789012345678901234567890123456789'); 26 | }); 27 | 28 | it('should create gist', () => { 29 | // @ts-expect-error 30 | const gist = new Gist(null, null); // Falsy author and ID values used to throw 31 | expect(gist).toBeInstanceOf(Gist); 32 | }); 33 | 34 | it('should fetch gist code', async () => { 35 | const gist = new Gist('testAuthor', 'testId'); 36 | await gist.load(); 37 | expect(globalThis.fetch).toHaveBeenCalled(); 38 | expect(gist.code).toBe(''); 39 | }); 40 | 41 | it('should URI encode gist code', async () => { 42 | const code = 'const test = "@#$";\nconsole.log(test);'; 43 | mockResponse.body = code; 44 | const gist = new Gist('testAuthor', 'testId'); 45 | await gist.load(); 46 | await expect.poll(() => gist.href).toMatch(/%40%23%24/); 47 | }); 48 | 49 | it('should parse gist properties', async () => { 50 | const title = 'testTitle'; 51 | const about = 'testAbout'; 52 | const code = `//bookmarklet_title:${title}\n//bookmarklet_about:${about}`; 53 | mockResponse.body = code; 54 | const gist = new Gist('testAuthor', 'testId'); 55 | await gist.load(); 56 | expect(gist.title).toBe(title); 57 | expect(gist.about).toBe(about); 58 | }); 59 | 60 | it('should parse gist variables', async () => { 61 | const key0 = 'test_key_0'; 62 | const key1 = '$$test_key_1$'; 63 | const key2 = 'test_key_2'; 64 | const code = `//bookmarklet_var: ${key0}\n// bookmarklet-var : ${key1}\nconst ${key2} = 'value'; //bookmarklet_var = ${key2}`; 65 | mockResponse.body = code; 66 | const gist = new Gist('testAuthor', 'testId'); 67 | await gist.load(); 68 | expect(gist.variables).toHaveProperty(key0); 69 | expect(gist.variables).toHaveProperty(key1); 70 | expect(gist.variables).toHaveProperty(key2); 71 | }); 72 | 73 | it('should parse gist text variables', async () => { 74 | const key0 = 'test_key_0'; 75 | const key1 = 'test_key_1'; 76 | const code = `//bookmarklet_var: ${key0}\n//bookmarklet_var(text): ${key1}`; 77 | mockResponse.body = code; 78 | const gist = new Gist('testAuthor', 'testId'); 79 | await gist.load(); 80 | expect(gist.variables[key0].type).toBe('text'); 81 | expect(gist.variables[key0].value).toBe(null); 82 | expect(gist.variables[key1].type).toBe('text'); 83 | expect(gist.variables[key1].value).toBe(null); 84 | gist.variables[key0].value = 'test_value_0'; 85 | gist.variables[key1].value = 'test_value_1'; 86 | expect(gist.variables[key0].value).toBe('test_value_0'); 87 | expect(gist.variables[key1].value).toBe('test_value_1'); 88 | }); 89 | 90 | it('should parse gist password variables', async () => { 91 | const key0 = 'test_key_0'; 92 | const code = `//bookmarklet_var(password): ${key0}`; 93 | mockResponse.body = code; 94 | const gist = new Gist('testAuthor', 'testId'); 95 | await gist.load(); 96 | expect(gist.variables[key0].type).toBe('password'); 97 | expect(gist.variables[key0].value).toBe(null); 98 | gist.variables[key0].value = 'test_value_0'; 99 | expect(gist.variables[key0].value).toBe('test_value_0'); 100 | }); 101 | 102 | it('should parse gist number variables', async () => { 103 | const key0 = 'test_key_0'; 104 | const code = `//bookmarklet_var(number): ${key0}`; 105 | mockResponse.body = code; 106 | const gist = new Gist('testAuthor', 'testId'); 107 | await gist.load(); 108 | expect(gist.variables[key0].type).toBe('number'); 109 | expect(gist.variables[key0].value).toBe(null); 110 | gist.variables[key0].value = '1.234'; 111 | expect(gist.variables[key0].value).toBe(1.234); 112 | gist.variables[key0].value = ''; 113 | expect(gist.variables[key0].value).toBe(null); 114 | gist.variables[key0].value = 'invalid'; 115 | expect(gist.variables[key0].value).toBe(null); 116 | }); 117 | 118 | it('should parse gist boolean variables', async () => { 119 | const key0 = 'test_key_0'; 120 | const code = `//bookmarklet_var(boolean): ${key0}`; 121 | mockResponse.body = code; 122 | const gist = new Gist('testAuthor', 'testId'); 123 | await gist.load(); 124 | expect(gist.variables[key0].type).toBe('boolean'); 125 | expect(gist.variables[key0].value).toBe(false); 126 | gist.variables[key0].value = 'something truthy'; 127 | expect(gist.variables[key0].value).toBe(true); 128 | gist.variables[key0].value = ''; 129 | expect(gist.variables[key0].value).toBe(false); 130 | }); 131 | 132 | it('should parse gist special variables', async () => { 133 | const author = 'testAuthor'; 134 | const id = 'testId'; 135 | const keyAuthor = 'test_key_author'; 136 | const keyId = 'test_key_id'; 137 | const keyUuid = 'test_key_uuid'; 138 | const code = `//bookmarklet_var(author): ${keyAuthor}\n//bookmarklet_var(id): ${keyId}\n//bookmarklet_var(uuid): ${keyUuid}\nconsole.log(${keyAuthor}, ${keyId}, ${keyUuid});`; 139 | mockResponse.body = code; 140 | const gist = new Gist(author, id); 141 | await gist.load(); 142 | await expect.poll(() => gist.href).toMatch(/testAuthor/); 143 | await expect.poll(() => gist.href).toMatch(/testId/); 144 | await expect.poll(() => gist.href).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); 145 | }); 146 | 147 | it('should fail to set gist special variables', async () => { 148 | const author = 'testAuthor'; 149 | const id = 'testId'; 150 | const keyAuthor = 'test_key_author'; 151 | const keyId = 'test_key_id'; 152 | const keyUuid = 'test_key_uuid'; 153 | const code = `//bookmarklet_var(author): ${keyAuthor}\n//bookmarklet_var(id): ${keyId}\n//bookmarklet_var(uuid): ${keyUuid}\n`; 154 | mockResponse.body = code; 155 | const gist = new Gist(author, id); 156 | await gist.load(); 157 | expect(() => gist.variables[keyAuthor].value = 'some new value').toThrow(); 158 | expect(() => gist.variables[keyId].value = 'some new value').toThrow(); 159 | expect(() => gist.variables[keyUuid].value = 'some new value').toThrow(); 160 | }); 161 | 162 | it('should parse gist variables types regardless of casing', async () => { 163 | const key0 = 'test_key_0'; 164 | const key1 = 'test_key_1'; 165 | const key2 = 'test_key_2'; 166 | const code = `//bookmarklet_var(TeXt): ${key0}\n//bookmarklet_var(NUMBER): ${key1}\n//bookmarklet_var(PASSword): ${key2}`; 167 | mockResponse.body = code; 168 | const gist = new Gist('testAuthor', 'testId'); 169 | await gist.load(); 170 | expect(gist.variables[key0].type).toBe('text'); 171 | expect(gist.variables[key1].type).toBe('number'); 172 | expect(gist.variables[key2].type).toBe('password'); 173 | }); 174 | 175 | it('should update gist variables', async () => { 176 | const key0 = 'test_key_0'; 177 | const code = `//bookmarklet_var: ${key0}`; 178 | mockResponse.body = code; 179 | const gist = new Gist('testAuthor', 'testId'); 180 | await gist.load(); 181 | gist.variables[key0].value = 'test_value_0' 182 | gist.code = `//bookmarklet_var: ${key0}\n//more`; 183 | expect(gist.variables[key0].value).toBe('test_value_0'); 184 | gist.code = `//bookmarklet_var(password): ${key0}\n//more`; 185 | expect(gist.variables[key0].value).toBeNull(); 186 | gist.code = `//empty`; 187 | expect(gist.variables).not.haveOwnProperty(key0); 188 | }); 189 | 190 | it('should use first gist variable definition', async () => { 191 | const key0 = 'test_key_0'; 192 | const code = `//bookmarklet_var: ${key0}\n//bookmarklet_var(number): ${key0}`; 193 | mockResponse.body = code; 194 | const gist = new Gist('testAuthor', 'testId'); 195 | await gist.load(); 196 | expect(gist.variables[key0].type).toBe('text'); 197 | }); 198 | 199 | it('should ignore invalid variable type', async () => { 200 | const key0 = 'test_key_0'; 201 | const code = `//bookmarklet_var(invalid): ${key0}`; 202 | mockResponse.body = code; 203 | const gist = new Gist('testAuthor', 'testId'); 204 | await gist.load(); 205 | expect(gist.variables[key0].type).toBe('text'); 206 | }); 207 | 208 | it('should skip syncing variables', async () => { 209 | const gist = new Gist('testAuthor', 'testId'); 210 | // @ts-expect-error 211 | gist._syncVariables(); 212 | }); 213 | 214 | it('should skip transpiling before loading', async () => { 215 | const gist = new Gist('testAuthor', 'testId'); 216 | gist.transpile(); 217 | expect(gist.href).toBeNull(); 218 | }); 219 | 220 | it('should skip transpiling duplicate code', async () => { 221 | const code = 'console.log("test");'; 222 | mockResponse.body = code; 223 | const gist = new Gist('testAuthor', 'testId'); 224 | const spy = vi.spyOn(gist, 'transpile'); 225 | await gist.load(); 226 | expect(spy).toHaveBeenCalledOnce(); 227 | mockResponse.body = code; // Use the same code 228 | await gist.load(); 229 | expect(spy).toHaveBeenCalledOnce(); 230 | }); 231 | 232 | it('should fail to fetch gist code', async () => { 233 | mockResponse.code = 500; // Server error 234 | const gist = new Gist('testAuthor', 'testId'); 235 | let promise = gist.load(); 236 | await expect(promise).rejects.toThrow(); 237 | mockResponse.code = null; // Network error 238 | promise = gist.load(); 239 | await expect(promise).rejects.toThrow(); 240 | }); 241 | 242 | it('should set size of gist', async () => { 243 | let code = `let a = "";\n${'a = "test test test";\n'.repeat(10)}`; 244 | mockResponse.body = code; 245 | const gist = new Gist('testAuthor', 'testId'); 246 | expect(gist.size).toBe('0 B'); 247 | await gist.load(); 248 | await expect.poll(() => gist.size).toBe('407 B'); 249 | code = `let a = "";\n${'a = "test test test";\n'.repeat(100)}`; 250 | mockResponse.body = code; 251 | await gist.load(); 252 | await expect.poll(() => gist.size).toBe('3.1 kB'); 253 | }); 254 | 255 | it('should transpile TypeScript', async () => { 256 | const code = 'const a: string ="";\nconsole.log(a);' 257 | mockResponse.body = code; 258 | const gist = new Gist('testAuthor', 'testId'); 259 | await gist.load(); 260 | await expect.poll(() => gist.size).toBe('114 B'); 261 | }); 262 | 263 | it('should include banner', async () => { 264 | const author = 'testAuthor'; 265 | const id = 'testId'; 266 | const code = 'const a: string ="";\nconsole.log(a);' 267 | mockResponse.body = code; 268 | const gist = new Gist(author, id); 269 | await gist.load(); 270 | await expect.poll(() => gist.href).toMatch(/bookmarkl\.ink\/testAuthor\/testId/); 271 | }); 272 | 273 | it('should fail transpilation', async () => { 274 | mockResponse.body = 'const test = "'; 275 | const gist = new Gist('testAuthor', 'testId'); 276 | await gist.load(); 277 | await expect.poll(() => gist.error).toBeInstanceOf(Error); 278 | }); 279 | 280 | it('should fail to set type of gist variable', async () => { 281 | const key0 = 'test_key_0'; 282 | const code = `//bookmarklet_var: ${key0}`; 283 | mockResponse.body = code; 284 | const gist = new Gist('testAuthor', 'testId'); 285 | await gist.load(); 286 | expect(() => { 287 | // @ts-expect-error 288 | gist.variables[key0].type = 'number'; 289 | }).toThrow(); 290 | }); 291 | -------------------------------------------------------------------------------- /test/Playground.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | import './__mock__/fetch'; 3 | import './__mock__/TextEncoder'; 4 | import './__mock__/esbuild'; 5 | import Playground from '../src/js/Playground'; 6 | 7 | it('should create playground', () => { 8 | const gist = new Playground(); 9 | expect(gist).toBeInstanceOf(Playground); 10 | expect(gist.author).toBe(''); 11 | expect(gist.id).toBe(''); 12 | expect(gist.url).toBe(''); 13 | }); 14 | 15 | it('should load example code', async () => { 16 | const gist = new Playground(); 17 | await gist.load(); 18 | expect(globalThis.fetch).not.toHaveBeenCalled(); 19 | expect(gist.code).toMatch(/bookmarklet-title: playground/); 20 | }); 21 | -------------------------------------------------------------------------------- /test/__mock__/Alpine.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | vi.mock('alpinejs', () => ({ 4 | default: { start: vi.fn() }, 5 | })); 6 | 7 | export default { 8 | init: async void }>(dataFn: () => T, assign?: object): Promise => { 9 | const data = dataFn(); 10 | Object.assign(data, assign); 11 | await data.init(); 12 | return data; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /test/__mock__/TextEncoder.ts: -------------------------------------------------------------------------------- 1 | class ESBuildAndJSDOMCompatibleTextEncoder extends TextEncoder { 2 | constructor() { 3 | super(); 4 | } 5 | 6 | encode(input: string): Uint8Array { 7 | if (typeof input !== 'string') throw new TypeError('input must be a string'); 8 | const decodedURI = decodeURIComponent(encodeURIComponent(input)); 9 | const arr = new Uint8Array(decodedURI.length); 10 | const chars = decodedURI.split(''); 11 | for (let i = 0; i < chars.length; i++) arr[i] = decodedURI[i].charCodeAt(0); 12 | return arr; 13 | } 14 | } 15 | 16 | Object.assign(globalThis, { TextEncoder: ESBuildAndJSDOMCompatibleTextEncoder }); 17 | -------------------------------------------------------------------------------- /test/__mock__/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | Object.assign(globalThis.navigator, { 4 | clipboard: { 5 | writeText: vi.fn().mockResolvedValue(undefined), 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /test/__mock__/document.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | // See https://github.com/jsdom/jsdom/issues/3729 4 | 5 | Object.assign(globalThis.document, { 6 | createRange: vi.fn(() => Object.assign(new Range(), { 7 | getBoundingClientRect: vi.fn(), 8 | getClientRects: vi.fn(() => ({ length: 0, item: () => null, [Symbol.iterator]: vi.fn() })), 9 | })), 10 | }); 11 | -------------------------------------------------------------------------------- /test/__mock__/esbuild.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | vi.mock('esbuild-wasm', async () => { 4 | const actual = await vi.importActual('esbuild-wasm'); 5 | return { 6 | ...actual, 7 | initialize: vi.fn(async () => {}), 8 | }; 9 | }); 10 | -------------------------------------------------------------------------------- /test/__mock__/fetch.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | type MockResponse = { 4 | code?: number | null; // Code null throws; non-2xx code resolves with ok = false 5 | body?: string; 6 | } 7 | 8 | export const mockResponse: MockResponse = {}; 9 | 10 | function reset(): void { 11 | mockResponse.code = undefined; 12 | mockResponse.body = undefined; 13 | } 14 | 15 | Object.assign(globalThis, { 16 | fetch: vi.fn(() => { 17 | if (mockResponse.code === null) return Promise.reject().finally(reset); 18 | const text = mockResponse.body || ''; 19 | return Promise.resolve({ 20 | ok: mockResponse.code === undefined || (mockResponse.code >= 200 && mockResponse.code < 300), 21 | status: mockResponse.code, 22 | text: () => Promise.resolve(text), 23 | arrayBuffer: () => Promise.resolve(new TextEncoder().encode(text)), 24 | }).finally(reset); 25 | }), 26 | }); 27 | -------------------------------------------------------------------------------- /test/__mock__/location.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | Object.assign(globalThis.window, { 4 | location: { 5 | assign: vi.fn((location) => { 6 | const url = new URL(location, globalThis.window.location.href); 7 | globalThis.window.location.href = url.href; 8 | globalThis.window.location.host = url.host; 9 | globalThis.window.location.hostname = url.hostname; 10 | globalThis.window.location.pathname = url.pathname; 11 | globalThis.window.location.hash = url.hash; 12 | }), 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /test/bookmarklet.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | import { mockResponse } from './__mock__/fetch'; 3 | import './__mock__/TextEncoder'; 4 | import './__mock__/esbuild'; 5 | import './__mock__/location'; 6 | import './__mock__/clipboard'; 7 | import './__mock__/document'; 8 | import Alpine from './__mock__/Alpine'; 9 | import bookmarklet from '../src/js/bookmarklet'; 10 | import Gist from '../src/js/Gist'; 11 | import Playground from '../src/js/Playground'; 12 | import BookmarkletError from '../src/js/error'; 13 | 14 | it('should start with defaults', async () => { 15 | const data = await Alpine.init(bookmarklet); 16 | expect(data).toHaveProperty('init'); 17 | expect(data.gist).toBeNull(); 18 | expect(data.error).toBeInstanceOf(Error); 19 | expect(data.edit).toBe(false); 20 | }); 21 | 22 | it('should set author and ID', async () => { 23 | const author = 'testAuthor'; 24 | const id = '01234567890123456789012345678901'; 25 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 26 | const data = await Alpine.init(bookmarklet); 27 | expect(data.gist?.author).toBe(author); 28 | expect(data.gist?.id).toBe(id); 29 | }); 30 | 31 | it('should set version', async () => { 32 | const author = 'testAuthor'; 33 | const id = '01234567890123456789012345678901'; 34 | const version = '0123456789012345678901234567890123456789'; 35 | window.location.assign(`https://bookmarkl.ink/${author}/${id}/${version}`); 36 | const data = await Alpine.init(bookmarklet); 37 | expect(data.gist?.version).toBe(version); 38 | }); 39 | 40 | it('should set version', async () => { 41 | const author = 'testAuthor'; 42 | const id = '01234567890123456789012345678901'; 43 | const file = 'test.js'; 44 | window.location.assign(`https://bookmarkl.ink/${author}/${id}//${file}`); 45 | const data = await Alpine.init(bookmarklet); 46 | expect(data.gist?.file).toBe(file); 47 | }); 48 | 49 | it('should set gist URL', async () => { 50 | const author = 'testAuthor'; 51 | const id = '01234567890123456789012345678901'; 52 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 53 | const data = await Alpine.init(bookmarklet); 54 | expect(data.gist?.url).toMatch(/gist\.github\.com/); 55 | }); 56 | 57 | it('should set title and about', async () => { 58 | const author = 'testAuthor'; 59 | const id = '01234567890123456789012345678901'; 60 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 61 | const title = 'testTitle'; 62 | const about = 'testAbout'; 63 | const code = `//bookmarklet_title:${title}\n//bookmarklet_about:${about}`; 64 | mockResponse.body = code; 65 | const data = await Alpine.init(bookmarklet); 66 | expect(data.gist?.title).toBe(title); 67 | expect(data.gist?.about).toBe(about); 68 | }); 69 | 70 | it('should set javascript href', async () => { 71 | const author = 'testAuthor'; 72 | const id = '01234567890123456789012345678901'; 73 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 74 | const code = 'const test = 1234;'; 75 | mockResponse.body = code; 76 | const data = await Alpine.init(bookmarklet); 77 | expect(data.gist?.author).toBe(author); 78 | await expect.poll(() => data.gist?.href).toMatch(/javascript:/); 79 | }); 80 | 81 | it('should copy javascript href', async () => { 82 | const author = 'testAuthor'; 83 | const id = '01234567890123456789012345678901'; 84 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 85 | const code = 'const test = 1234;'; 86 | mockResponse.body = code; 87 | const data = await Alpine.init(bookmarklet); 88 | await expect.poll(() => data.gist?.href).toMatch(/javascript:/); 89 | data.copy(); 90 | expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalled(); 91 | }); 92 | 93 | it('should vary href based on variables', async () => { 94 | const key0 = 'test_key_0'; 95 | const author = 'testAuthor'; 96 | const id = '01234567890123456789012345678901'; 97 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 98 | const code = `//bookmarklet_var: ${key0}\nconsole.log(${key0});`; 99 | mockResponse.body = code; 100 | const data = await Alpine.init(bookmarklet); 101 | expect(data.gist).toBeInstanceOf(Gist); 102 | const gist = data.gist as Gist; 103 | await expect.poll(() => gist.href).toMatch(/javascript:/); 104 | expect(gist.variables[key0].value).toBe(null); 105 | const old = gist.href; 106 | gist.variables[key0].value = 'test_value_0'; 107 | await expect.poll(() => gist.href).not.toBe(old); 108 | }); 109 | 110 | it('should default to editing', async () => { 111 | const author = 'testAuthor'; 112 | const id = '01234567890123456789012345678901'; 113 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 114 | window.location.hash = '#edit'; 115 | const data = await Alpine.init(bookmarklet); 116 | expect(data.edit).toBe(true); 117 | }); 118 | 119 | it('should load playground', async () => { 120 | window.location.assign(`https://bookmarkl.ink/playground`); 121 | const data = await Alpine.init(bookmarklet); 122 | expect(data.edit).toBe(true); 123 | expect(data.gist).toBeInstanceOf(Playground); 124 | expect(data.gist?.title).toBe('playground'); 125 | }); 126 | 127 | it('should fail to load bookmarklet', async () => { 128 | mockResponse.code = 500; 129 | const author = 'testAuthor'; 130 | const id = '01234567890123456789012345678901'; 131 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 132 | const data = await Alpine.init(bookmarklet); 133 | expect(data.error).toBeInstanceOf(BookmarkletError); 134 | expect(data.error?.code).toBe(500); 135 | expect(data.error?.message).toBe('failed to fetch javascript code'); 136 | }); 137 | 138 | it('should fail to transpile bookmarklet', async () => { 139 | const author = 'testAuthor'; 140 | const id = '01234567890123456789012345678901'; 141 | window.location.assign(`https://bookmarkl.ink/${author}/${id}`); 142 | const code = 'const test = "'; 143 | mockResponse.body = code; 144 | const data = await Alpine.init(bookmarklet, { $refs: {} }); 145 | expect(data.error).toBeNull(); 146 | await expect.poll(() => data.gist?.error).toBeInstanceOf(Error); 147 | }); 148 | -------------------------------------------------------------------------------- /test/editor.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect, vi } from 'vitest'; 2 | import './__mock__/document'; 3 | import insertEditor from '../src/js/editor'; 4 | 5 | it('should insert CodeMirror editor', () => { 6 | const element = document.createElement('div'); 7 | const code = '// Test'; 8 | const callback = vi.fn(); 9 | const editor = insertEditor(element, code, callback); 10 | editor.dispatch({ 11 | changes: [{ from: 0, to: code.length, insert: '// Updated' }], 12 | }); 13 | expect(callback).toHaveBeenCalled(); 14 | editor.dispatch({ changes: [] }); // Should fire callback without changing document 15 | }); 16 | -------------------------------------------------------------------------------- /test/home.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | import Alpine from './__mock__/Alpine'; 3 | import home from '../src/js/home'; 4 | 5 | it('should start with defaults', async () => { 6 | const data = await Alpine.init(home); 7 | expect(data).toHaveProperty('init'); 8 | expect(data).toHaveProperty('gistUrl'); 9 | expect(data).toHaveProperty('bookmarkletUrl'); 10 | expect(data).toHaveProperty('valid'); 11 | }); 12 | 13 | it('should set bookmarklet URL', async () => { 14 | const author = 'testAuthor'; 15 | const id = '01234567890123456789012345678901'; 16 | const data = await Alpine.init(home); 17 | data.gistUrl = `https://gist.github.com/${author}/${id}`; 18 | expect(data.bookmarkletUrl).toBe(`/${author}/${id}`); 19 | expect(data.valid).toBe(true); 20 | }); 21 | 22 | it('should set URL with version', async () => { 23 | const author = 'testAuthor'; 24 | const id = '01234567890123456789012345678901'; 25 | const version = '0123456789012345678901234567890123456789'; 26 | const data = await Alpine.init(home); 27 | data.gistUrl = `https://gist.github.com/${author}/${id}/${version}`; 28 | expect(data.bookmarkletUrl).toBe(`/${author}/${id}/${version}`); 29 | }); 30 | 31 | it('should set URL with file', async () => { 32 | const author = 'testAuthor'; 33 | const id = '01234567890123456789012345678901'; 34 | const file = 'test.js'; 35 | const data = await Alpine.init(home); 36 | data.gistUrl = `https://gist.github.com/${author}/${id}/${file}`; 37 | expect(data.bookmarkletUrl).toBe(`/${author}/${id}/${file}`); 38 | }); 39 | 40 | it('should set URL with version and file', async () => { 41 | const author = 'testAuthor'; 42 | const id = '01234567890123456789012345678901'; 43 | const version = '0123456789012345678901234567890123456789'; 44 | const file = 'test.js'; 45 | const data = await Alpine.init(home); 46 | data.gistUrl = `https://gist.github.com/${author}/${id}/${version}/${file}`; 47 | expect(data.bookmarkletUrl).toBe(`/${author}/${id}/${version}/${file}`); 48 | }); 49 | 50 | it('should have init method', async () => { 51 | const data = await Alpine.init(home); 52 | expect(data.init).toBeInstanceOf(Function); 53 | expect(data.init()).toBe(undefined); 54 | }); 55 | 56 | it('should fail to set bookmarklet URL', async () => { 57 | const author = 'testAuthor'; 58 | const id = '01234567890123456789012345678901'; 59 | const data = await Alpine.init(home); 60 | data.gistUrl = `https://example.com/${author}/${id}`; 61 | expect(data.bookmarkletUrl).toBe(null); 62 | expect(data.valid).toBe(false); 63 | }); 64 | -------------------------------------------------------------------------------- /test/parsePath.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | import { parseGistPath, parseBookmarkletPath } from '../src/js/parsePath'; 3 | 4 | it('should parse bookmarklet URL', () => { 5 | const author = 'testAuthor'; 6 | const id = '01234567890123456789012345678901'; 7 | const url = `https://bookmarkl.ink/${author}/${id}`; 8 | const parsed = parseBookmarkletPath(url); 9 | expect(parsed.author).toBe(author); 10 | expect(parsed.id).toBe(id); 11 | }); 12 | 13 | it('should parse bookmarklet URL with trailing slash', () => { 14 | const author = 'testAuthor'; 15 | const id = '01234567890123456789012345678901'; 16 | const url = `https://bookmarkl.ink/${author}/${id}/`; 17 | const parsed = parseBookmarkletPath(url); 18 | expect(parsed.author).toBe(author); 19 | expect(parsed.id).toBe(id); 20 | }); 21 | 22 | it('should parse bookmarklet URL with numeric ID', () => { 23 | const author = 'testAuthor'; 24 | const id = '0123456'; 25 | const url = `https://bookmarkl.ink/${author}/${id}/`; 26 | const parsed = parseBookmarkletPath(url); 27 | expect(parsed.author).toBe(author); 28 | expect(parsed.id).toBe(id); 29 | }); 30 | 31 | it('should parse bookmarklet URL with short alphanumeric ID', () => { 32 | const author = 'testAuthor'; 33 | const id = 'abcdef0123abcdef0123'; 34 | const url = `https://bookmarkl.ink/${author}/${id}/`; 35 | const parsed = parseBookmarkletPath(url); 36 | expect(parsed.author).toBe(author); 37 | expect(parsed.id).toBe(id); 38 | }); 39 | 40 | it('should parse bookmarklet version', () => { 41 | const author = 'testAuthor'; 42 | const id = '01234567890123456789012345678901'; 43 | const version = '0123456789012345678901234567890123456789'; 44 | const url = `https://bookmarkl.ink/${author}/${id}/${version}`; 45 | const parsed = parseBookmarkletPath(url); 46 | expect(parsed.version).toBe(version); 47 | }); 48 | 49 | it('should parse bookmarklet file with blank version', () => { 50 | const author = 'testAuthor'; 51 | const id = '01234567890123456789012345678901'; 52 | const file = 'test.js'; 53 | const url = `https://bookmarkl.ink/${author}/${id}//${file}`; 54 | const parsed = parseBookmarkletPath(url); 55 | expect(parsed.file).toBe(file); 56 | }); 57 | 58 | it('should parse bookmarklet file with missing version', () => { 59 | const author = 'testAuthor'; 60 | const id = '01234567890123456789012345678901'; 61 | const file = 'test.js'; 62 | const url = `https://bookmarkl.ink/${author}/${id}/${file}`; 63 | const parsed = parseBookmarkletPath(url); 64 | expect(parsed.file).toBe(file); 65 | }); 66 | 67 | it('should parse bookmarklet version and file', () => { 68 | const author = 'testAuthor'; 69 | const id = '01234567890123456789012345678901'; 70 | const version = '0123456789012345678901234567890123456789'; 71 | const file = 'test.js'; 72 | const url = `https://bookmarkl.ink/${author}/${id}/${version}/${file}`; 73 | const parsed = parseBookmarkletPath(url); 74 | expect(parsed.file).toBe(file); 75 | }); 76 | 77 | it('should parse bookmarklet version and file with trailing slash', () => { 78 | const author = 'testAuthor'; 79 | const id = '01234567890123456789012345678901'; 80 | const version = '0123456789012345678901234567890123456789'; 81 | const file = 'test.js'; 82 | const url = `https://bookmarkl.ink/${author}/${id}/${version}/${file}/`; 83 | const parsed = parseBookmarkletPath(url); 84 | expect(parsed.file).toBe(file); 85 | }); 86 | 87 | it('should parse github.com URL', () => { 88 | const author = 'testAuthor'; 89 | const id = '01234567890123456789012345678901'; 90 | const url = `https://gist.github.com/${author}/${id}`; 91 | const parsed = parseGistPath(url); 92 | expect(parsed.author).toBe(author); 93 | expect(parsed.id).toBe(id); 94 | }); 95 | 96 | it('should parse githubusercontent.com URL', () => { 97 | const author = 'testAuthor'; 98 | const id = '01234567890123456789012345678901'; 99 | const url = `https://gist.githubusercontent.com/${author}/${id}`; 100 | const parsed = parseGistPath(url); 101 | expect(parsed.author).toBe(author); 102 | expect(parsed.id).toBe(id); 103 | }); 104 | 105 | it('should parse numeric ID', () => { 106 | const author = 'testAuthor'; 107 | const id = '0123456'; 108 | const url = `https://gist.githubusercontent.com/${author}/${id}/`; 109 | const parsed = parseGistPath(url); 110 | expect(parsed.author).toBe(author); 111 | expect(parsed.id).toBe(id); 112 | }); 113 | 114 | it('should parse short alphanumeric ID', () => { 115 | const author = 'testAuthor'; 116 | const id = 'abcdef0123abcdef0123'; 117 | const url = `https://gist.githubusercontent.com/${author}/${id}/`; 118 | const parsed = parseGistPath(url); 119 | expect(parsed.author).toBe(author); 120 | expect(parsed.id).toBe(id); 121 | }); 122 | 123 | it('should parse author and ID with trailing slash', () => { 124 | const author = 'testAuthor'; 125 | const id = '01234567890123456789012345678901'; 126 | const url = `https://gist.githubusercontent.com/${author}/${id}/`; 127 | const parsed = parseGistPath(url); 128 | expect(parsed.author).toBe(author); 129 | expect(parsed.id).toBe(id); 130 | }); 131 | 132 | it('should parse gist version', () => { 133 | const author = 'testAuthor'; 134 | const id = '01234567890123456789012345678901'; 135 | const version = '0123456789012345678901234567890123456789'; 136 | const url = `https://gist.github.com/${author}/${id}/${version}/`; 137 | const parsed = parseGistPath(url); 138 | expect(parsed.version).toBe(version); 139 | }); 140 | 141 | it('should parse gist file', () => { 142 | const author = 'testAuthor'; 143 | const id = '01234567890123456789012345678901'; 144 | const file = 'test.js'; 145 | const url = `https://gist.github.com/${author}/${id}/${file}`; 146 | const parsed = parseGistPath(url); 147 | expect(parsed.file).toBe(file); 148 | }); 149 | 150 | it('should parse gist version and file', () => { 151 | const author = 'testAuthor'; 152 | const id = '01234567890123456789012345678901'; 153 | const version = '0123456789012345678901234567890123456789'; 154 | const file = 'test.js'; 155 | const url = `https://gist.github.com/${author}/${id}/${version}/${file}`; 156 | const parsed = parseGistPath(url); 157 | expect(parsed.version).toBe(version); 158 | expect(parsed.file).toBe(file); 159 | }); 160 | 161 | it('should parse gist version and file with trailing slash', () => { 162 | const author = 'testAuthor'; 163 | const id = '01234567890123456789012345678901'; 164 | const version = '0123456789012345678901234567890123456789'; 165 | const file = 'test.js'; 166 | const url = `https://gist.github.com/${author}/${id}/${version}/${file}/`; 167 | const parsed = parseGistPath(url); 168 | expect(parsed.version).toBe(version); 169 | expect(parsed.file).toBe(file); 170 | }); 171 | 172 | it('should fail invalid bookmarklet ID', () => { 173 | const author = 'testAuthor'; 174 | const id = 'testId'; 175 | const url = `https://bookmarkl.ink/${author}/${id}`; 176 | expect(() => { 177 | parseBookmarkletPath(url); 178 | }).toThrow('invalid path'); 179 | }); 180 | 181 | it('should fail to parse invalid URL path', () => { 182 | const url = ''; 183 | expect(() => { 184 | parseGistPath(url); 185 | }).toThrow('invalid path'); 186 | }); 187 | 188 | it('should fail to parse invalid hostname', () => { 189 | const url = 'https://example.com'; 190 | expect(() => { 191 | parseGistPath(url); 192 | }).toThrow('invalid hostname'); 193 | }); 194 | 195 | it('should fail to parse invalid path', () => { 196 | const url = 'https://gist.github.com'; 197 | expect(() => { 198 | parseGistPath(url); 199 | }).toThrow('invalid path'); 200 | }); 201 | -------------------------------------------------------------------------------- /test/router.test.ts: -------------------------------------------------------------------------------- 1 | import { vi, it, expect } from 'vitest'; 2 | import { CloudFrontRequestEvent, Context, CloudFrontHeaders } from 'aws-lambda'; 3 | import { handler } from '../src/js/router'; 4 | 5 | it('should route home', async () => { 6 | const request = { uri: '' }; 7 | const event = { 8 | Records: [{ 9 | cf: { request }, 10 | }], 11 | } as CloudFrontRequestEvent; 12 | const callback = vi.fn(); 13 | await handler(event, {} as Context, callback); 14 | expect(callback).toHaveBeenCalledWith(null, request); 15 | }); 16 | 17 | it('should route bookmarklet', async () => { 18 | const request = { 19 | uri: '/test', 20 | headers: { 21 | 'accept': [{ value: 'text/html' }], 22 | } as CloudFrontHeaders, 23 | }; 24 | const event = { 25 | Records: [{ 26 | cf: { request }, 27 | }], 28 | } as CloudFrontRequestEvent; 29 | const callback = vi.fn(); 30 | await handler(event, {} as Context, callback); 31 | expect(callback).toHaveBeenCalledWith(null, { ...request, uri: '/bookmarklet.html' }); 32 | }); 33 | --------------------------------------------------------------------------------