├── .gitignore ├── social_logo.png ├── .vim └── coc-settings.json ├── .github └── workflows │ ├── cicd.yml │ └── udd.yml ├── types.ts ├── css_functions_test.ts ├── LICENSE ├── cli ├── unregister.ts ├── init.ts ├── register.ts ├── denote.ts └── serve.ts ├── deps.ts ├── server_test.ts ├── denote.yml ├── example.yml ├── velociraptor.yml ├── dynamodb.ts ├── css_functions.ts ├── render_html_test.ts ├── logo.svg ├── README.md ├── server.ts └── render_html.ts /.gitignore: -------------------------------------------------------------------------------- 1 | !.vim 2 | cov_profile 3 | *_server.js 4 | -------------------------------------------------------------------------------- /social_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawarimidoll/denote/HEAD/social_logo.png -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true, 5 | "prettier.disableLanguages": ["typescript", "javascript", "markedown"], 6 | "typescript.format.enabled": false 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: cicd 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | cicd: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - uses: denoland/setup-deno@v1 11 | - uses: jurassiscripts/setup-velociraptor@v1 12 | - name: ci 13 | run: VR_HOOKS=false vr ci 14 | - name: cd 15 | run: VR_HOOKS=false vr cd --token '${{ secrets.DENOTE_TOKEN }}' 16 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export type CssObject = Record>; 2 | 3 | // TODO: use disableFlags 4 | export type DisableFlag = "rain" | "nav" | "rounded-image"; 5 | 6 | export type ListGroup = { 7 | icon: string; 8 | items: ListItem[]; 9 | }; 10 | 11 | export type ListItem = { 12 | text?: string; 13 | icon?: string; 14 | link?: string; 15 | }; 16 | 17 | export type ConfigObject = { 18 | name: string; 19 | disable: DisableFlag[]; 20 | description: string; 21 | image: string; 22 | favicon: string; 23 | twitter: string; 24 | list: { 25 | [key: string]: ListGroup; 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /css_functions_test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from "./deps.ts"; 2 | import { getRandomInt, objectToCss } from "./css_functions.ts"; 3 | import { CssObject } from "./types.ts"; 4 | 5 | Deno.test("objectToCss", () => { 6 | const cssObject: CssObject = { 7 | "#main": { 8 | width: "100%", 9 | padding: "1rem 0.5rem", 10 | }, 11 | ".nav": { 12 | display: "flex", 13 | "justify-content": "space-around", 14 | margin: "0 auto", 15 | padding: "0.5rem", 16 | width: "100%", 17 | }, 18 | ".nav>a": { 19 | display: "block", 20 | }, 21 | }; 22 | const css = 23 | "#main{width:100%;padding:1rem 0.5rem}.nav{display:flex;justify-content:space-around;margin:0 auto;padding:0.5rem;width:100%}.nav>a{display:block}"; 24 | 25 | assertEquals(objectToCss(cssObject), css); 26 | }); 27 | 28 | Deno.test("getRandomInt", () => { 29 | // test 10 times 30 | for (let i = 0; i < 10; i++) { 31 | const num = getRandomInt(10); 32 | assert(0 <= num && num <= 9); 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /.github/workflows/udd.yml: -------------------------------------------------------------------------------- 1 | name: update-deno-dependencies 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 0" 6 | 7 | jobs: 8 | udd: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - uses: denoland/setup-deno@v1 13 | - name: Update dependencies 14 | run: > 15 | deno run -A https://deno.land/x/udd/main.ts 16 | $(find . -name "*.ts") --test="deno test -Ar --no-check=remote" 17 | - name: Create Pull Request 18 | uses: peter-evans/create-pull-request@v3 19 | with: 20 | commit-message: "chore(deps): update deno dependencies" 21 | title: Update Deno Dependencies 22 | body: > 23 | Automated updates by [deno-udd](https://github.com/hayd/deno-udd) 24 | and [create-pull-request](https://github.com/peter-evans/create-pull-request) 25 | GitHub action 26 | branch: update-deno-dependencies 27 | author: GitHub 28 | delete-branch: true 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 カワリミ人形 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 | -------------------------------------------------------------------------------- /cli/unregister.ts: -------------------------------------------------------------------------------- 1 | const usage = ` 2 | denote unregister 3 | 4 | Remove the page from denote.deno.dev. 5 | 6 | Example: 7 | denote unregister --name your-name --token your-token 8 | 9 | 'name' and 'token' options are both required. 10 | 11 | Options: 12 | -n, --name [name] Your name. 13 | -t, --token [token] Your token. 14 | -h, --help Shows the help message. 15 | `.trim(); 16 | 17 | function error(str: string): void { 18 | console.error("\nError: " + str); 19 | } 20 | 21 | export async function unregister({ 22 | debug, 23 | name, 24 | help, 25 | token, 26 | }: { 27 | debug: boolean; 28 | name: boolean; 29 | token: boolean; 30 | help: string; 31 | }) { 32 | if (debug) { 33 | console.log({ 34 | debug, 35 | name, 36 | token, 37 | help, 38 | }); 39 | } 40 | 41 | if (help) { 42 | console.log(usage); 43 | return 0; 44 | } 45 | 46 | if (!name || !token) { 47 | console.log(usage); 48 | error("name and token are both required"); 49 | return 1; 50 | } 51 | 52 | try { 53 | const result = await fetch("https://denote.deno.dev", { 54 | method: "DELETE", 55 | headers: { "content-type": "application/json" }, 56 | body: JSON.stringify({ name, token }), 57 | }); 58 | 59 | const json = await result.json(); 60 | console.log(json?.message); 61 | 62 | return 0; 63 | } catch (e) { 64 | error(e); 65 | return 1; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | assertThrows, 5 | } from "https://deno.land/std@0.140.0/testing/asserts.ts"; 6 | export { 7 | decode, 8 | encode, 9 | } from "https://deno.land/std@0.140.0/encoding/base64.ts"; 10 | export { 11 | parse as parseYaml, 12 | stringify as stringifyYaml, 13 | } from "https://deno.land/std@0.140.0/encoding/yaml.ts"; 14 | export { createHash } from "https://deno.land/std@0.140.0/hash/mod.ts"; 15 | export { basename, extname } from "https://deno.land/std@0.140.0/path/mod.ts"; 16 | export { debounce } from "https://deno.land/std@0.140.0/async/mod.ts"; 17 | export { parse as parseCli } from "https://deno.land/std@0.140.0/flags/mod.ts"; 18 | export { mapValues } from "https://deno.land/std@0.140.0/collections/mod.ts"; 19 | 20 | export { 21 | json, 22 | serve as sift, 23 | validateRequest, 24 | } from "https://deno.land/x/sift@0.5.0/mod.ts"; 25 | export { lt as semverLessThan } from "https://deno.land/x/semver@v1.4.0/mod.ts"; 26 | export { gunzip, gzip } from "https://deno.land/x/compress@v0.4.5/gzip/gzip.ts"; 27 | export { 28 | AMP, 29 | GT, 30 | LT, 31 | QUOT, 32 | tag, 33 | } from "https://deno.land/x/markup_tag@0.3.0/mod.ts"; 34 | import shuffle from "https://deno.land/x/shuffle@v1.0.1/mod.ts"; 35 | export { shuffle }; 36 | export { range } from "https://deno.land/x/it_range@v1.0.3/range.mjs"; 37 | 38 | export { 39 | DeleteItemCommand, 40 | DynamoDBClient, 41 | GetItemCommand, 42 | PutItemCommand, 43 | } from "https://esm.sh/@aws-sdk/client-dynamodb"; 44 | -------------------------------------------------------------------------------- /server_test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from "./deps.ts"; 2 | Deno.env.set("AWS_ACCESS_KEY_ID", "dummy-id"); 3 | Deno.env.set("AWS_SECRET_ACCESS_KEY", "dummy-key"); 4 | import { 5 | decodeConfig, 6 | encodeConfig, 7 | validateConfig, 8 | validateName, 9 | validateToken, 10 | } from "./server.ts"; 11 | 12 | Deno.test("encode and decode", () => { 13 | const config = { 14 | description: "", 15 | twitter: "twitter", 16 | list: { 17 | id1: { 18 | icon: "feather/github", 19 | items: [{ icon: "feather/github" }], 20 | }, 21 | }, 22 | }; 23 | const configStr = JSON.stringify(config); 24 | assertEquals(decodeConfig(encodeConfig(configStr)), config); 25 | }); 26 | 27 | Deno.test("validateConfig", () => { 28 | assert( 29 | validateConfig( 30 | `{"list":{"id1":{"icon":"devicons/github","items":[{"text":"github","link":"https://github.com/"}]}}}`, 31 | ), 32 | ); 33 | assert(!validateConfig("{}")); 34 | assert(!validateConfig("")); 35 | }); 36 | 37 | Deno.test("validateName", () => { 38 | assert(validateName("this-is-valid_123")); 39 | assert(!validateName("invalid name")); 40 | assert(!validateName("")); 41 | assert(!validateName("o")); 42 | assert(!validateName("_no")); 43 | assert(!validateName("名前")); 44 | }); 45 | 46 | Deno.test("validateToken", () => { 47 | assert(validateToken("this-is-valid_123")); 48 | assert(!validateToken("invalid token")); 49 | assert(!validateToken("")); 50 | assert(!validateToken("short")); 51 | assert(!validateToken("秘密")); 52 | }); 53 | -------------------------------------------------------------------------------- /denote.yml: -------------------------------------------------------------------------------- 1 | name: Denote 2 | description: A minimal profile page generator for Deno Deploy 3 | image: https://raw.githubusercontent.com/kawarimidoll/denote/main/logo.svg 4 | list: 5 | overview: 6 | icon: entypo/light-bulb 7 | items: 8 | - icon: entypo/news 9 | text: Denote your profile... 10 | - icon: entypo/code 11 | text: Denote your skills... 12 | - icon: entypo/cake 13 | text: Denote your likes... 14 | - icon: entypo/link 15 | text: Denote your links... 16 | - icon: entypo/emoji-flirt 17 | text: And everything! 18 | get-started: 19 | icon: entypo/rocket 20 | items: 21 | - text: Quick start 22 | - icon: material/numeric-1-circle 23 | text: > 24 | Run 'deno install --allow-read --allow-write --allow-net 25 | --no-check --force https://deno.land/x/denote/cli/denote.ts' 26 | - icon: material/numeric-2-circle 27 | text: Run 'denote init denote.yml' to create a config file 28 | - icon: material/numeric-3-circle 29 | text: Edit the config file as you like 30 | - icon: material/numeric-4-circle 31 | text: > 32 | Run 'denote register denote.yml --name your-name 33 | --token your-token' to register the config file 34 | - icon: material/numeric-5-circle 35 | text: Visit https://denote.deno.dev/[your-name] 36 | more: 37 | icon: entypo/flower 38 | items: 39 | - icon: entypo/power-plug 40 | text: Hosting on Deno Deploy 41 | link: https://deno.com/deploy 42 | - icon: entypo/github 43 | text: Auto-update by GitHub Actions 44 | link: https://github.com/kawarimidoll/denote/blob/main/.github/workflows/cicd.yml 45 | - icon: entypo/folder-images 46 | text: Icons by icongr.am 47 | link: https://icongr.am/ 48 | -------------------------------------------------------------------------------- /example.yml: -------------------------------------------------------------------------------- 1 | # name: deno 2 | # The name shown on the top of the page. 3 | # When this is left blank, your page path (denote.deno.dev/???) is used. 4 | # This is also used in the title of html. 5 | 6 | # image: https://deno.land/logo.svg 7 | # The URL of the main image of the page. 8 | # This is also used in 'og:image'. 9 | # When this is left blank, it is just skipped. 10 | 11 | # favicon: https://deno.land/favicon.svg 12 | # The URL of the favicon of the page. 13 | # When this is left blank, Denote logo is used. 14 | 15 | # description: A secure JavaScript and TypeScript runtime 16 | # The comments shown under the page name. 17 | # This is also used in 'og:description'. 18 | # When this is left blank, it is just skipped. 19 | 20 | # twitter: @deno_land 21 | # Your twitter username. 22 | # This is used in 'twitter:site'. 23 | # When this is left blank, it is just skipped. 24 | 25 | list: 26 | # The list of the group of the contents. 27 | # These values are required. 28 | 29 | id-1: 30 | # The key of the group is used as the ID of html elements. 31 | # This must have 'icon' and 'items'. 32 | 33 | icon: fontawesome/font-awesome 34 | # The icon of this group. 35 | # The available icons are from icongr.am. 36 | 37 | items: 38 | # The list of the contents. 39 | # Each item can have 'icon', 'text', and 'link'. 40 | 41 | - icon: jam/info 42 | text: this is a text with an icon 43 | - text: this is a just text 44 | - icon: octicons/octoface 45 | text: this is a link to GitHub 46 | link: https://github.com 47 | - text: this is a link to Twitter 48 | link: https://twitter.com 49 | - link: https://gitlab.com 50 | 51 | id-2: 52 | icon: feather/anchor 53 | items: 54 | - icon: clarity/block 55 | text: this is the second block 56 | - icon: simple/deno 57 | -------------------------------------------------------------------------------- /velociraptor.yml: -------------------------------------------------------------------------------- 1 | noCheck: true 2 | 3 | scripts: 4 | init: 5 | desc: Builds config file 6 | cmd: cli/denote.ts init 7 | allow: 8 | - write 9 | - read 10 | - net 11 | 12 | serve: 13 | desc: Starts local server 14 | cmd: cli/denote.ts serve 15 | allow: 16 | - net 17 | - read 18 | watch: true 19 | 20 | register: 21 | desc: Publishes page 22 | cmd: cli/denote.ts register 23 | allow: 24 | - net 25 | - read 26 | 27 | unregister: 28 | desc: Removes page 29 | cmd: cli/denote.ts unregister 30 | allow: 31 | - net 32 | - read 33 | 34 | start: 35 | cmd: deployctl run --no-check --env=.env --watch ./server.ts 36 | 37 | deps: 38 | desc: Update dependencies with ensuring pass tests 39 | cmd: udd deps.ts --test="vr test" 40 | 41 | lint: 42 | desc: Runs lint 43 | cmd: deno lint --ignore=cov_profile 44 | 45 | fmt: 46 | desc: Runs format 47 | cmd: deno fmt --ignore=cov_profile 48 | 49 | pre-commit: 50 | cmd: | 51 | FILES=$(git diff --staged --name-only --diff-filter=ACMR "*.ts") 52 | [ -z "$FILES" ] && exit 0 53 | echo "$FILES" | xargs deno lint 54 | echo "$FILES" | xargs deno fmt 55 | # echo "$FILES" | xargs git add 56 | desc: Lints and formats staged files 57 | gitHook: pre-commit 58 | 59 | test: 60 | desc: Runs the tests 61 | cmd: deno test --reload --allow-net 62 | allow: 63 | - env 64 | gitHook: pre-push 65 | 66 | cov: 67 | desc: Shows uncovered lists 68 | cmd: 69 | - vr test --coverage=cov_profile 70 | - deno coverage cov_profile 71 | 72 | ci: 73 | desc: Runs lint, check format and test 74 | cmd: 75 | - vr lint 76 | - vr fmt --check 77 | - vr test 78 | 79 | cd: 80 | desc: Publishes latest denote.yml 81 | cmd: 82 | - vr register denote.yml --name denote 83 | 84 | commitlint: 85 | # dependencies: commitlint and @commitlint/config-conventional 86 | # yarn global add commitlint @commitlint/config-conventional 87 | desc: Checks commit messages format with commitlint 88 | cmd: commitlint -x @commitlint/config-conventional -e ${GIT_ARGS[1]} 89 | gitHook: commit-msg 90 | -------------------------------------------------------------------------------- /cli/init.ts: -------------------------------------------------------------------------------- 1 | import { extname, parseYaml } from "./../deps.ts"; 2 | 3 | const usage = ` 4 | denote init 5 | 6 | Generates sample config file with given name. 7 | 8 | Example: 9 | denote init profile.yml 10 | 11 | The output should be YAML or JSON file. 12 | 13 | Options: 14 | -f, --force Overwrites the output file without confirmation. 15 | -h, --help Shows the help message. 16 | `.trim(); 17 | 18 | function error(str: string): void { 19 | console.error("\nError: " + str); 20 | } 21 | 22 | export async function init({ 23 | debug, 24 | force, 25 | help, 26 | filename, 27 | }: { 28 | debug: boolean; 29 | force: boolean; 30 | help: string; 31 | filename: string; 32 | }) { 33 | if (debug) { 34 | console.log({ 35 | debug, 36 | force, 37 | help, 38 | filename, 39 | }); 40 | } 41 | 42 | if (help) { 43 | console.log(usage); 44 | return 0; 45 | } 46 | 47 | if (!filename) { 48 | console.log(usage); 49 | error("source file is required"); 50 | return 1; 51 | } 52 | 53 | const ext = extname(filename); 54 | if (![".yml", ".yaml", ".json"].includes(ext)) { 55 | console.log(usage); 56 | error("invalid file is passed as an argument"); 57 | return 1; 58 | } 59 | 60 | let config = ""; 61 | try { 62 | const response = await fetch( 63 | "https://raw.githubusercontent.com/kawarimidoll/denote/main/example.yml", 64 | ); 65 | config = await response.text(); 66 | 67 | if (ext == ".json") { 68 | config = JSON.stringify(parseYaml(config), null, 2); 69 | } 70 | 71 | const stat = await Deno.lstat(filename); 72 | if (stat.isDirectory) { 73 | error(`the output path ${filename} is directory`); 74 | return 1; 75 | } 76 | if ( 77 | force || confirm( 78 | `The output path ${filename} already exists. Are you sure to overwrite this file?`, 79 | ) 80 | ) { 81 | Deno.writeTextFileSync(filename, config); 82 | console.log(`Server file is successfully created: ${filename}`); 83 | return 0; 84 | } else { 85 | console.warn("Aborting"); 86 | return 1; 87 | } 88 | } catch (e) { 89 | if (e.name === "NotFound") { 90 | Deno.writeTextFileSync(filename, config); 91 | console.log(`Server file is successfully created: ${filename}`); 92 | return 0; 93 | } 94 | error(e); 95 | return 1; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /dynamodb.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteItemCommand, 3 | DynamoDBClient, 4 | GetItemCommand, 5 | PutItemCommand, 6 | } from "./deps.ts"; 7 | 8 | const accessKeyId = Deno.env.get("AWS_ACCESS_KEY_ID") || "dummy-id"; 9 | const secretAccessKey = Deno.env.get("AWS_SECRET_ACCESS_KEY") || "dummy-key"; 10 | if (accessKeyId === "dummy-id" || secretAccessKey === "dummy-key") { 11 | console.warn("missing credentials. starts with dummy values."); 12 | } 13 | 14 | const client = new DynamoDBClient({ 15 | region: "us-east-1", 16 | credentials: { accessKeyId, secretAccessKey }, 17 | }); 18 | 19 | const tableName = "Denote"; 20 | 21 | export interface DenoteSchema { 22 | name: string; 23 | hashedToken: string; 24 | config: string; 25 | } 26 | 27 | export async function putItem(data: DenoteSchema) { 28 | try { 29 | const response = await client.send( 30 | new PutItemCommand({ 31 | TableName: tableName, 32 | Item: { 33 | // Here 'S' implies that the value is of type string 34 | name: { S: data.name }, 35 | hashedToken: { S: data.hashedToken }, 36 | config: { S: data.config }, 37 | }, 38 | }), 39 | ); 40 | 41 | console.log(response); 42 | const { $metadata: { httpStatusCode } } = response; 43 | 44 | return httpStatusCode === 200; 45 | } catch (error) { 46 | console.log(error); 47 | } 48 | return false; 49 | } 50 | 51 | export async function getItem(name: string) { 52 | try { 53 | const response = await client.send( 54 | new GetItemCommand({ 55 | TableName: tableName, 56 | Key: { 57 | name: { S: name }, 58 | }, 59 | }), 60 | ); 61 | 62 | console.log(response); 63 | const { Item } = response; 64 | 65 | if (Item) { 66 | return { 67 | name: Item.name.S, 68 | hashedToken: Item.hashedToken.S, 69 | config: Item.config.S, 70 | }; 71 | } 72 | } catch (error) { 73 | console.log(error); 74 | } 75 | return null; 76 | } 77 | 78 | export async function deleteItem(name: string) { 79 | try { 80 | const response = await client.send( 81 | new DeleteItemCommand({ 82 | TableName: tableName, 83 | Key: { 84 | name: { S: name }, 85 | }, 86 | }), 87 | ); 88 | 89 | console.log(response); 90 | const { $metadata: { httpStatusCode } } = response; 91 | 92 | return httpStatusCode === 200; 93 | } catch (error) { 94 | console.log(error); 95 | } 96 | return false; 97 | } 98 | -------------------------------------------------------------------------------- /css_functions.ts: -------------------------------------------------------------------------------- 1 | import { parseYaml, range, shuffle } from "./deps.ts"; 2 | import { CssObject } from "./types.ts"; 3 | 4 | export function objectToCss(cssObject: CssObject) { 5 | return Object.entries(cssObject).map(([selector, attributes]) => 6 | selector + "{" + 7 | Object.entries(attributes).map(([k, v]) => `${k}:${v}`).join(";") + 8 | "}" 9 | ).join(""); 10 | } 11 | 12 | export function getRandomInt(max: number) { 13 | return Math.floor(Math.random() * max); 14 | } 15 | 16 | export function getDenoteCss(rainCount: number, noRound = false) { 17 | const cssYml = ` 18 | body: 19 | display: flex 20 | justify-content: center 21 | margin: 0 22 | text-align: center 23 | scroll-behavior: smooth 24 | font-family: "sans-serif,monospace" 25 | background-color: "#111" 26 | color: azure 27 | a: 28 | color: inherit 29 | h2: 30 | margin: "-2rem auto 0" 31 | padding-top: 4rem 32 | footer: 33 | font-size: smaller 34 | img: 35 | display: block 36 | margin: 0 auto 37 | .inline: 38 | display: inline 39 | "#main": 40 | width: 100% 41 | max-width: 800px 42 | padding: 1rem 0.5rem 43 | .main-image: 44 | ${noRound ? "" : "border-radius: 50%"} 45 | width: 260px 46 | height: 260px 47 | object-fit: cover 48 | .description: 49 | margin-bottom: 2rem 50 | .list-group: 51 | max-width: 500px 52 | margin: 0 auto 53 | margin-bottom: 2rem 54 | .list-item: 55 | border-radius: 5px 56 | border: thin solid azure 57 | margin: 0.5rem auto 58 | padding: 0.5rem 2rem 59 | .nav-box: 60 | background-color: "#111" 61 | position: sticky 62 | top: 0 63 | border-bottom: thin solid azure 64 | .nav: 65 | display: flex 66 | justify-content: space-around 67 | margin: 0 auto 68 | padding: 0.5rem 69 | width: 100% 70 | max-width: 300px 71 | .nav>a: 72 | display: block 73 | .ex-link: 74 | display: inline-block 75 | margin: 0 .25rem 76 | .rain: 77 | user-select: none 78 | pointer-events: none 79 | z-index: 1 80 | position: fixed 81 | width: 120% 82 | height: 100% 83 | display: flex 84 | justify-content: space-around 85 | transform: rotate(10deg) 86 | .drop: 87 | width: 1px 88 | height: 10vh 89 | background: white 90 | animation-name: fall_down 91 | animation-iteration-count: infinite 92 | margin-top: "-20vh" 93 | animation-timing-function: linear 94 | ` + shuffle([...range(rainCount)]).map((num, idx) => ` 95 | .drop:nth-child(${idx}): 96 | animation-delay: ${num * 50}ms 97 | animation-duration: ${getRandomInt(300) + 350}ms 98 | opacity: 0.${getRandomInt(3) + 2}`).join(""); 99 | 100 | return objectToCss(parseYaml(cssYml) as CssObject) + 101 | `@keyframes fall_down{to{margin-top:120vh}}`; 102 | } 103 | -------------------------------------------------------------------------------- /cli/register.ts: -------------------------------------------------------------------------------- 1 | import { extname, parseYaml } from "./../deps.ts"; 2 | 3 | const usage = ` 4 | denote register 5 | 6 | Publish the page on denote.deno.dev with given config file. 7 | 8 | Example: 9 | denote register profile.yml --name your-name --token your-token 10 | 11 | The input should be YAML or JSON file. 12 | You can use URL when the config file is published on the web. 13 | 'name' and 'token' options are both required. 14 | 15 | Options: 16 | -n, --name [name] Your name. Use as https://denote.deno.dev/[name] 17 | -t, --token [token] Your token. It hashed and saved. 18 | -h, --help Shows the help message. 19 | `.trim(); 20 | 21 | function isURL(str: string) { 22 | try { 23 | new URL(str); 24 | return true; 25 | } catch (_) { 26 | return false; 27 | } 28 | } 29 | async function getText(url: string): Promise { 30 | const response = await fetch(url); 31 | if (response.ok) { 32 | return await response.text(); 33 | } 34 | return ""; 35 | } 36 | 37 | function error(str: string): void { 38 | console.error("\nError: " + str); 39 | } 40 | 41 | export async function register({ 42 | debug, 43 | name, 44 | help, 45 | token, 46 | filename, 47 | }: { 48 | debug: boolean; 49 | name: boolean; 50 | token: boolean; 51 | help: string; 52 | filename: string; 53 | }) { 54 | if (debug) { 55 | console.log({ 56 | debug, 57 | name, 58 | token, 59 | help, 60 | filename, 61 | }); 62 | } 63 | 64 | if (help) { 65 | console.log(usage); 66 | return 0; 67 | } 68 | 69 | if (!filename) { 70 | console.log(usage); 71 | error("source file is required"); 72 | return 1; 73 | } 74 | 75 | const ext = extname(filename); 76 | if (![".yml", ".yaml", ".json"].includes(ext)) { 77 | console.log(usage); 78 | error("invalid file is passed as an argument"); 79 | return 1; 80 | } 81 | 82 | if (!name || !token) { 83 | console.log(usage); 84 | error("name and token are both required"); 85 | return 1; 86 | } 87 | 88 | try { 89 | const contents = isURL(filename) 90 | ? await getText(filename) 91 | : await Deno.readTextFile(filename); 92 | 93 | // validate and minify 94 | const config = JSON.stringify(parseYaml(contents)); 95 | 96 | const result = await fetch("https://denote.deno.dev", { 97 | method: "POST", 98 | headers: { "content-type": "application/json" }, 99 | body: JSON.stringify({ name, token, config }), 100 | }); 101 | 102 | const json = await result.json(); 103 | console.log(json?.message); 104 | 105 | return 0; 106 | } catch (e) { 107 | error(e); 108 | return 1; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /render_html_test.ts: -------------------------------------------------------------------------------- 1 | import { AMP, assertEquals, assertThrows, GT, LT, QUOT } from "./deps.ts"; 2 | import { DENOTE_LOGO, icongram, loadConfig, sanitize } from "./render_html.ts"; 3 | import { ConfigObject } from "./types.ts"; 4 | 5 | Deno.test("sanitize", () => { 6 | assertEquals(sanitize(""), ""); 7 | assertEquals(sanitize("normal text"), "normal text"); 8 | assertEquals(sanitize(""), LT + "chevron" + GT); 9 | assertEquals( 10 | sanitize(`and & "quotes"`), 11 | "and " + AMP + " " + QUOT + "quotes" + QUOT, 12 | ); 13 | }); 14 | 15 | Deno.test("loadConfig", () => { 16 | const config: Partial = { 17 | description: "", 18 | twitter: "twitter", 19 | list: { 20 | id1: { 21 | icon: "feather/github", 22 | items: [ 23 | { 24 | icon: "feather/github", 25 | link: "http://github.com", 26 | }, 27 | ], 28 | }, 29 | id2: { 30 | icon: "devicons/gitlab", 31 | items: [ 32 | { 33 | text: "gitlab", 34 | }, 35 | ], 36 | }, 37 | }, 38 | }; 39 | 40 | const processed: ConfigObject = { 41 | name: "Your name will be here", 42 | disable: [], 43 | description: "<description>", 44 | image: "", 45 | favicon: DENOTE_LOGO, 46 | twitter: "@twitter", 47 | list: { 48 | id1: { 49 | icon: "feather/github", 50 | items: [ 51 | { 52 | icon: "feather/github", 53 | text: "", 54 | link: "http://github.com", 55 | }, 56 | ], 57 | }, 58 | id2: { 59 | icon: "devicons/gitlab", 60 | items: [ 61 | { 62 | icon: undefined, 63 | text: "gitlab", 64 | link: "", 65 | }, 66 | ], 67 | }, 68 | }, 69 | }; 70 | assertEquals(loadConfig(config), processed); 71 | 72 | assertThrows(() => { 73 | loadConfig({ 74 | name: "", 75 | disable: [], 76 | twitter: "", 77 | description: "", 78 | image: "", 79 | favicon: "", 80 | list: {}, 81 | }); 82 | }); 83 | }); 84 | 85 | Deno.test("icongram", () => { 86 | assertEquals( 87 | icongram("clarity/github"), 88 | `clarity/github`, 89 | ); 90 | 91 | assertEquals( 92 | icongram("jam/flower", 30), 93 | `jam/flower`, 94 | ); 95 | 96 | assertEquals( 97 | icongram("feather/external-link", 12, { class: "ex-link" }), 98 | `feather/external-link`, 99 | ); 100 | 101 | assertThrows(() => { 102 | icongram("foo"); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cli/denote.ts: -------------------------------------------------------------------------------- 1 | import { parseCli, semverLessThan } from "./../deps.ts"; 2 | import { init } from "./init.ts"; 3 | import { serve } from "./serve.ts"; 4 | import { register } from "./register.ts"; 5 | import { unregister } from "./unregister.ts"; 6 | 7 | const NAME = "denote"; 8 | const VERSION = "0.2.1"; 9 | const MINIMUM_DENO_VERSION = "1.13.0"; 10 | const versionInfo = `${NAME} ${VERSION}`; 11 | 12 | const helpMsg = ` 13 | ${versionInfo} 14 | 15 | A minimal profile page generator for Deno Deploy. 16 | 17 | Subcommands: 18 | i, init Generates sample config file with given name. 19 | s, serve Runs local server with given config file. 20 | r, register Publish the page on denote.deno.dev with given config file. 21 | u, unregister Remove the page from denote.deno.dev. 22 | 23 | Options: 24 | -v, --version Shows the version number. 25 | -h, --help Shows the help message. 26 | -d, --debug Reveals the given arguments. 27 | `.trim(); 28 | 29 | export async function main(cliArgs: string[]) { 30 | if (semverLessThan(Deno.version.deno, MINIMUM_DENO_VERSION)) { 31 | console.error("The Deno version you are using is too old."); 32 | console.error(`Please update to Deno ${MINIMUM_DENO_VERSION} or later.`); 33 | console.error("To do this run `deno upgrade`."); 34 | return 1; 35 | } 36 | 37 | const { 38 | "_": args, 39 | debug, 40 | force, 41 | help, 42 | name, 43 | port, 44 | token, 45 | version, 46 | watch, 47 | } = parseCli( 48 | cliArgs, 49 | { 50 | boolean: [ 51 | "debug", 52 | "force", 53 | "help", 54 | "version", 55 | "watch", 56 | ], 57 | string: [ 58 | "name", 59 | "output", 60 | "port", 61 | "token", 62 | ], 63 | alias: { 64 | d: "debug", 65 | f: "force", 66 | h: "help", 67 | n: "name", 68 | p: "port", 69 | t: "token", 70 | v: "version", 71 | w: "watch", 72 | }, 73 | default: { 74 | port: "8080", 75 | }, 76 | }, 77 | ); 78 | 79 | if (version) { 80 | console.log(versionInfo); 81 | return 0; 82 | } 83 | 84 | const [subcommand, filename] = args.map((arg) => `${arg}`); 85 | 86 | if (subcommand === "init" || subcommand === "i") { 87 | return await init({ 88 | debug, 89 | force, 90 | help, 91 | filename, 92 | }); 93 | } 94 | if (subcommand === "serve" || subcommand === "s") { 95 | return await serve({ 96 | debug, 97 | help, 98 | port, 99 | filename, 100 | watch, 101 | }); 102 | } 103 | if (subcommand === "register" || subcommand === "r") { 104 | return await register({ 105 | debug, 106 | help, 107 | name, 108 | token, 109 | filename, 110 | }); 111 | } 112 | if (subcommand === "unregister" || subcommand === "u") { 113 | return await unregister({ 114 | debug, 115 | help, 116 | name, 117 | token, 118 | }); 119 | } 120 | if (help) { 121 | console.log(helpMsg); 122 | return 0; 123 | } 124 | console.log(helpMsg); 125 | return 1; 126 | } 127 | 128 | if (import.meta.main) { 129 | try { 130 | Deno.exit(await main(Deno.args)); 131 | } catch (e) { 132 | console.error(e); 133 | Deno.exit(1); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /cli/serve.ts: -------------------------------------------------------------------------------- 1 | import { debounce, extname, parseYaml } from "./../deps.ts"; 2 | import { renderHtml } from "./../render_html.ts"; 3 | import { ConfigObject } from "./../types.ts"; 4 | 5 | const usage = ` 6 | denote serve 7 | 8 | Runs local server without creating any files. 9 | 10 | Example: 11 | denote serve ./denote.yml 12 | 13 | The input should be YAML or JSON file. 14 | 15 | Options: 16 | -p, --port Specifies the port to local server. Default is 8080. 17 | -w, --watch Restarts the local server when the source file is updated. 18 | -h, --help Shows the help message. 19 | `.trim(); 20 | 21 | function error(str: string): void { 22 | console.error("\nError: " + str); 23 | } 24 | 25 | export async function serve({ 26 | debug, 27 | help, 28 | port, 29 | filename, 30 | watch, 31 | }: { 32 | debug: boolean; 33 | help: string; 34 | port: string; 35 | filename: string; 36 | watch: boolean; 37 | }) { 38 | if (debug) { 39 | console.log({ 40 | debug, 41 | help, 42 | port, 43 | filename, 44 | watch, 45 | }); 46 | } 47 | 48 | if (help) { 49 | console.log(usage); 50 | return 0; 51 | } 52 | 53 | if (!filename) { 54 | console.log(usage); 55 | error("source file is required"); 56 | return 1; 57 | } 58 | if (![".yaml", ".yml", ".json"].includes(extname(filename))) { 59 | console.log(usage); 60 | error("invalid file is passed as an argument"); 61 | return 1; 62 | } 63 | if (!/^[1-9]\d*$/.test(port)) { 64 | console.log(usage); 65 | error("invalid port number is detected"); 66 | return 1; 67 | } 68 | 69 | if (watch) { 70 | await runServerWithWatching(filename, Number(port), { debug }); 71 | } else { 72 | await runServer(filename, Number(port), { debug }); 73 | } 74 | return 0; 75 | } 76 | 77 | let html = ""; 78 | async function runServer( 79 | source: string, 80 | port: number, 81 | { debug = false } = {}, 82 | ) { 83 | const config = parseYaml(Deno.readTextFileSync(source)) as ConfigObject; 84 | html = renderHtml(config, debug); 85 | console.log( 86 | `HTTP webserver running. Access it at: http://localhost:${port}/`, 87 | ); 88 | const headers = new Headers({ "content-type": "text/html" }); 89 | for await (const conn of Deno.listen({ port })) { 90 | (async () => { 91 | for await (const { respondWith } of Deno.serveHttp(conn)) { 92 | respondWith(new Response(html, { status: 200, headers })); 93 | } 94 | })(); 95 | } 96 | } 97 | 98 | // [Build a live reloader and explore Deno! 🦕 - DEV Community](https://dev.to/otanriverdi/let-s-explore-deno-by-building-a-live-reloader-j47) 99 | // https://github.com/denoland/deployctl/blob/main/src/subcommands/run.ts 100 | export async function runServerWithWatching( 101 | source: string, 102 | port: number, 103 | { interval = 300, debug = false } = {}, 104 | ) { 105 | runServer(source, port, { debug }); 106 | 107 | const watcher = Deno.watchFs(source); 108 | 109 | const rebuild = debounce(() => { 110 | console.log("File change detected"); 111 | console.log("Rebuilding..."); 112 | const config = parseYaml(Deno.readTextFileSync(source)) as ConfigObject; 113 | html = renderHtml(config, debug); 114 | console.log("Local server is updated"); 115 | console.log("Watching for changes..."); 116 | }, interval); 117 | 118 | console.log("Watching for changes..."); 119 | for await (const event of watcher) { 120 | if (event.kind !== "modify") { 121 | continue; 122 | } 123 | rebuild(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Denote 2 | 3 |

4 | logo 5 |

6 | 7 |

8 | ci/cd 9 | deno.land 10 | velociraptor 11 | LICENSE 12 | deno.land 13 | 14 |

15 | 16 | A minimal profile page generator for Deno Deploy that _denotes_ you 17 | 18 | [Demo](https://denote.deno.dev/denote) 19 | 20 | ## Getting started 21 | 22 | ``` 23 | $ # to install denote cli 24 | $ deno install --allow-read --allow-write --allow-net --no-check --force https://deno.land/x/denote/cli/denote.ts 25 | 26 | $ # to install example config 27 | $ denote init ./denote.yml 28 | 29 | $ # to see in local server 30 | $ denote serve ./denote.yml 31 | 32 | $ # to create or update the page 33 | $ denote register ./denote.yml --name your-name --token your-token 34 | 35 | $ # to remove the page 36 | $ denote unregister --name your-name --token your-token 37 | ``` 38 | 39 | - `name`: The name of your page (`https://denote.deno.dev/[name]`). This must be 40 | unique in Denote and matched with `/^[a-z][a-z0-9_-]{2,64}$/`. 41 | - `token`: The secret token. This is hashed and saved. This must matched with 42 | `/^[!-~]{8,128}$/`. 43 | 44 | ## API 45 | 46 | You can call API manually with any tools like `curl`. The endpoint is 47 | `https://denote.deno.dev/`. 48 | 49 | ### Register 50 | 51 | `POST` request to register your data. You can use this to create and update. 52 | 53 | `name`, `token` and `config` (the config JSON object) are required. 54 | 55 | `name` and `token` that you saved are required when you update. 56 | 57 | ### Unregister 58 | 59 | `DELETE` request to unregister your data. `name` and `token` that you saved are 60 | required. 61 | 62 | ## Config keys 63 | 64 | The config data has these keys. 65 | 66 | - name 67 | - The name shown on the top of the page. When this is left blank, your page 68 | path (`https://denote.deno.dev/[name]`) is used. This is also used in the 69 | title of html. 70 | - image 71 | - The URL of the main image of the page. This is also used in 'og:image'. When 72 | this is left blank, it is just skipped. 73 | - favicon 74 | - The URL of the favicon of the page. When this is left blank, 75 | [Denote logo](https://github.com/kawarimidoll/denote/blob/main/logo.svg) is 76 | used. 77 | - description 78 | - The comments shown under the page name. This is also used in 79 | 'og:description'. When this is left blank, it is just skipped. 80 | - twitter 81 | - Your twitter username. This is used in 'twitter:site'. When this is left 82 | blank, it is just skipped. 83 | - list **required** 84 | - The list of the group of the contents. List group that has unique ids are 85 | required. 86 | - The key of the group is used as the ID of html elements. This must have 87 | 'icon' and 'items'. 'icon' is the icon of this group. 'items' is the list of 88 | the contents. 89 | - Each item in 'items' can have 'icon', 'text', and 'link'. 90 | 91 | See also 92 | [example.yml](https://github.com/kawarimidoll/denote/blob/main/example.yml) that 93 | generated by `denote init` and 94 | [denote.yml](https://github.com/kawarimidoll/denote/blob/main/denote.yml) that 95 | running on [Demo](https://denote.deno.dev/denote). 96 | 97 | Visit [icongram](https://icongr.am) to search available icons. 98 | 99 | ## Prior arts 100 | 101 | - inspired by [Linktree](https://linktr.ee/) 102 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createHash, 3 | decode, 4 | encode, 5 | gunzip, 6 | gzip, 7 | json, 8 | sift, 9 | validateRequest, 10 | } from "./deps.ts"; 11 | import { renderHtml } from "./render_html.ts"; 12 | import { ConfigObject } from "./types.ts"; 13 | import { deleteItem, getItem, putItem } from "./dynamodb.ts"; 14 | 15 | export function applyHash(token: string) { 16 | return createHash("sha256").update(`${token}`).toString(); 17 | } 18 | 19 | const NAME_REGEX = /^[a-z][a-z0-9_-]{2,64}$/; 20 | const TOKEN_REGEX = /^[!-~]{8,128}$/; 21 | 22 | export function validateName(name: string) { 23 | return NAME_REGEX.test(name); 24 | } 25 | 26 | export function validateToken(token: string) { 27 | return TOKEN_REGEX.test(token); 28 | } 29 | 30 | export function validateConfig(config: string) { 31 | try { 32 | const { list } = JSON.parse(config); 33 | return !!(list); 34 | } catch (_) { 35 | return false; 36 | } 37 | } 38 | export function encodeConfig(rawConfig: string) { 39 | // use parse and stringify to minify json 40 | return encode( 41 | gzip( 42 | new TextEncoder().encode( 43 | JSON.stringify(JSON.parse(rawConfig)), 44 | ), 45 | ), 46 | ); 47 | } 48 | export function decodeConfig(compressedConfig: string) { 49 | return JSON.parse( 50 | new TextDecoder().decode(gunzip(decode(compressedConfig))), 51 | ) as ConfigObject; 52 | } 53 | 54 | sift({ 55 | "/": async (request) => { 56 | const { error, body } = await validateRequest(request, { 57 | GET: {}, 58 | POST: { 59 | body: ["name", "token", "config"], 60 | }, 61 | DELETE: { 62 | body: ["name", "token"], 63 | }, 64 | }); 65 | if (error) { 66 | return json({ error: error.message }, { status: error.status }); 67 | } 68 | if (body == null) { 69 | return json({ message: "could not process the data." }, { status: 400 }); 70 | } 71 | 72 | if (request.method === "GET") { 73 | return json({ 74 | message: 75 | "please access with POST to create new data or DELETE to delete the data. https://github.com/kawarimidoll/denote", 76 | }); 77 | } 78 | 79 | const name = `${body.name}`; 80 | const token = `${body.token}`; 81 | const rawConfig = `${body.config}`; 82 | 83 | if (!validateName(name)) { 84 | return json({ 85 | message: `invalid name. this must match with ${NAME_REGEX}`, 86 | }); 87 | } 88 | if (!validateToken(token)) { 89 | return json({ 90 | message: `invalid token. this must match with ${TOKEN_REGEX}`, 91 | }); 92 | } 93 | if (!validateConfig(rawConfig)) { 94 | return json({ 95 | message: 96 | "invalid config. this must be a valid JSON which contains 'list' key.", 97 | }); 98 | } 99 | 100 | const hashedToken = applyHash(name + token); 101 | 102 | const config = encodeConfig(rawConfig); 103 | 104 | if (request.method === "POST") { 105 | const item = await getItem(name); 106 | if (item && item.hashedToken !== hashedToken) { 107 | return json({ 108 | message: 109 | `the token is incorrect. use correct token to update the record or use other name to create new one.`, 110 | }, { status: 401 }); 111 | } 112 | 113 | const result = await putItem({ name, hashedToken, config }); 114 | if (result) { 115 | return json({ 116 | message: "data is saved successfully. do not forget your token.", 117 | name, 118 | token, 119 | }); 120 | } 121 | } 122 | 123 | if (request.method === "DELETE") { 124 | const item = await getItem(name); 125 | if (!item) { 126 | return json({ 127 | message: `the name '${name}' is not exist.`, 128 | }, { status: 404 }); 129 | } 130 | 131 | if (item.hashedToken !== hashedToken) { 132 | return json({ 133 | message: 134 | `the name '${name}' is already exist but the token is incorrect. use correct token to delete the record.`, 135 | }, { status: 401 }); 136 | } 137 | 138 | const result = await deleteItem(name); 139 | if (result) { 140 | return json({ 141 | message: `the data of the name '${name}' is deleted successfully.`, 142 | }); 143 | } 144 | } 145 | return json({ message: "something went wrong." }, { status: 500 }); 146 | }, 147 | "/:slug": async (request) => { 148 | const { pathname } = new URL(request.url); 149 | // /aaa/bbb/ccc -> aaa 150 | const name = pathname.slice(1).replace(/\/.*/, ""); 151 | console.log(name); 152 | 153 | const item = await getItem(name); 154 | 155 | if (!(item?.config)) { 156 | return json({ message: "not found." }, { status: 404 }); 157 | } 158 | 159 | try { 160 | const rawConfig = decodeConfig(item.config); 161 | rawConfig.name ||= name; 162 | console.log({ rawConfig }); 163 | const html = renderHtml(rawConfig, true); 164 | return new Response(html, { headers: { "content-type": "text/html" } }); 165 | } catch (error) { 166 | console.warn(error); 167 | } 168 | return json({ message: "something went wrong." }, { status: 500 }); 169 | }, 170 | 404: () => json({ message: "not found." }, { status: 404 }), 171 | }); 172 | -------------------------------------------------------------------------------- /render_html.ts: -------------------------------------------------------------------------------- 1 | import { AMP, GT, LT, mapValues, QUOT, tag as h } from "./deps.ts"; 2 | import { getDenoteCss } from "./css_functions.ts"; 3 | 4 | import { ConfigObject, ListGroup, ListItem } from "./types.ts"; 5 | 6 | export const DENOTE_LOGO = 7 | "https://raw.githubusercontent.com/kawarimidoll/denote/main/logo.svg"; 8 | 9 | export function sanitize(str?: string): string { 10 | return (str || "") 11 | .replaceAll("&", AMP) 12 | .replaceAll("<", LT) 13 | .replaceAll(">", GT) 14 | .replaceAll(`"`, QUOT); 15 | } 16 | 17 | export function loadConfig(config: Partial): ConfigObject { 18 | config.name ||= "Your name will be here"; 19 | 20 | if (!config.list || Object.keys(config.list).length === 0) { 21 | throw new Error("list is empty"); 22 | } 23 | const name = sanitize(config.name); 24 | const description = sanitize(config.description); 25 | const image = encodeURI(config.image || ""); 26 | const favicon = encodeURI(config.favicon || DENOTE_LOGO); 27 | const twitter = config.twitter?.replace(/^([^@])/, "@$1"); 28 | const list = mapValues( 29 | config.list, 30 | (group: ListGroup) => ({ 31 | icon: group.icon, 32 | items: group.items.map((item) => ({ 33 | icon: item.icon, 34 | text: sanitize(item.text), 35 | link: item.link ? encodeURI(item.link) : "", 36 | })), 37 | }), 38 | ); 39 | return { 40 | name, 41 | description, 42 | image, 43 | favicon, 44 | twitter, 45 | disable: (config.disable || []), 46 | list, 47 | } as ConfigObject; 48 | } 49 | 50 | export function icongram(name: string, size = 20, attrs = {}) { 51 | const regex = 52 | /^(clarity|devicon|entypo|feather|fontawesome|jam|material|octicons|simple)\/[\w-]+$/; 53 | if (!regex.test(name)) { 54 | throw new Error( 55 | `invalid icon name: ${name}, icon name should be matched with ${regex}`, 56 | ); 57 | } 58 | 59 | return h("img", { 60 | src: `https://icongr.am/${name}.svg?size=${size}&color=f0ffff`, 61 | alt: name, 62 | ...attrs, 63 | }); 64 | } 65 | 66 | const exLink = icongram("feather/external-link", 12, { class: "ex-link" }); 67 | export function renderListItem(listItem: ListItem) { 68 | const { icon, text, link: href } = listItem; 69 | 70 | const iconText = (icon = "", text = "") => 71 | h( 72 | "div", 73 | { class: "list-item" }, 74 | icon ? icongram(icon) : "", 75 | text ? h("div", text) : "", 76 | ); 77 | 78 | return href 79 | ? h("a", { href }, iconText(icon, (text || href) + exLink)) 80 | : iconText(icon, text); 81 | } 82 | 83 | const rainCount = 30; 84 | 85 | export function renderHtmlHead(config: ConfigObject) { 86 | const { name, description, disable, image, favicon, twitter } = config; 87 | const url = `https://denote.deno.dev/${name}`; 88 | const viewport = "width=device-width,initial-scale=1.0"; 89 | const title = `${name} | Denote`; 90 | const noRound = disable.includes("rounded-image"); 91 | 92 | return h( 93 | "head", 94 | { prefix: "og:http://ogp.me/ns#" }, 95 | h("meta", { charset: "utf-8" }), 96 | h("meta", { name: "viewport", content: viewport }), 97 | h("meta", { name: "description", content: description }), 98 | h("meta", { property: "og:title", content: title }), 99 | h("meta", { property: "og:description", content: description }), 100 | h("meta", { property: "og:url", content: url }), 101 | h("meta", { property: "og:type", content: "profile" }), 102 | h("meta", { property: "og:site_name", content: "Denote" }), 103 | image ? h("meta", { property: "og:image", content: image }) : "", 104 | h("meta", { name: "twitter:card", content: "summary" }), 105 | twitter ? h("meta", { name: "twitter:site", content: twitter }) : "", 106 | h("title", title), 107 | h("style", getDenoteCss(rainCount, noRound)), 108 | h("link", { rel: "icon", href: favicon }), 109 | h("link", { rel: "canonical", href: url }), 110 | ); 111 | } 112 | 113 | export function renderHtmlBody(config: ConfigObject) { 114 | const { name, description, disable, image } = config; 115 | const list = Object.entries(config.list); 116 | const alt = `${name} main image`; 117 | const noNav = disable.includes("nav"); 118 | const noRain = disable.includes("rain"); 119 | 120 | return h( 121 | "body", 122 | noRain ? "" : h( 123 | "div", 124 | { class: "rain" }, 125 | h("div", { class: "drop" }).repeat(rainCount), 126 | ), 127 | h( 128 | "div", 129 | { id: "main" }, 130 | image ? h("img", { alt, class: "main-image", src: image }) : "", 131 | h("h1", name), 132 | description ? h("div", { class: "description" }, description) : "", 133 | noNav ? "" : h("div", "Click to jump...") + h( 134 | "div", 135 | { class: "nav-box" }, 136 | h( 137 | "div", 138 | { class: "nav" }, 139 | ...list.map(([id, { icon }]) => 140 | h("a", { href: `#${id}` }, icongram(icon, 26)) 141 | ), 142 | ), 143 | ), 144 | h( 145 | "div", 146 | { class: "list-group" }, 147 | ...list.map(([id, { icon, items }]) => 148 | h("h2", { id }, icongram(icon, 40)) + 149 | items.map((listItem) => renderListItem(listItem)).join("") 150 | ), 151 | ), 152 | h( 153 | "footer", 154 | h("a", { href: "https://github.com/kawarimidoll/denote" }, "Denote"), 155 | exLink, 156 | icongram("jam/heart-f", 18, { class: "inline" }), 157 | h("a", { href: "https://deno.com/deploy" }, "Deno Deploy"), 158 | exLink, 159 | ), 160 | ), 161 | ); 162 | } 163 | 164 | export function renderHtml(rawConfig: ConfigObject, debug = false) { 165 | const config = loadConfig(rawConfig); 166 | if (debug) { 167 | console.log(config); 168 | } 169 | return "" + 170 | h("html", renderHtmlHead(config), renderHtmlBody(config)); 171 | } 172 | --------------------------------------------------------------------------------