├── .arc ├── .env.example ├── .github └── workflows │ ├── cla.yml │ └── deploy.yml ├── .gitignore ├── README.md ├── app ├── api │ ├── admin │ │ ├── index.mjs │ │ └── webmentions.mjs │ ├── h-card.json │ ├── h-card.json.example │ ├── index.mjs │ ├── login.mjs │ ├── logout.mjs │ ├── posts │ │ └── $$.mjs │ ├── rss.mjs │ └── webmention.mjs ├── blog │ └── posts │ │ ├── 2023-02-23-pale-blue-dot.md │ │ ├── 2023-02-28-we-choose.md │ │ ├── 2023-03-01-element-styling-reference.md │ │ └── 2023-03-08-how-to-use.md ├── elements │ ├── admin-webmentions.mjs │ ├── blog-card.mjs │ ├── blog-pagination-button.mjs │ ├── blog-pagination.mjs │ ├── blog-posts.mjs │ ├── h-card.mjs │ ├── my-h-card.mjs │ ├── site-container.mjs │ ├── site-footer.mjs │ ├── site-header.mjs │ ├── site-layout.mjs │ ├── submit-button.mjs │ ├── text-input.mjs │ └── webmentions-list.mjs ├── head.mjs ├── lib │ ├── hljs-line-wrapper.mjs │ └── markdown-class-mappings.mjs └── pages │ ├── admin │ └── index.html │ ├── index.mjs │ ├── login.html │ └── posts │ └── $$.mjs ├── jobs ├── events │ ├── check-webmention │ │ └── index.mjs │ ├── incoming-webmention │ │ └── index.mjs │ └── outgoing-webmention │ │ └── index.mjs └── scheduled │ └── check-rss │ └── index.mjs ├── license.md ├── package-lock.json ├── package.json ├── prefs.arc ├── public ├── css │ ├── a11y-dark.min.css │ └── global.css ├── favicon.svg └── images │ ├── pale-blue-dot.jpg │ ├── the-moon.jpg │ ├── theme-elegant-dark.png │ ├── theme-elegant-light.png │ ├── theme-minimal-dark.png │ └── theme-minimal-light.png ├── shared ├── posts.mjs └── webmentions.mjs ├── src └── plugins │ ├── create-post-metadata.js │ └── create-rss-feed.js ├── theme-elegant.json └── theme-minimal.json /.arc: -------------------------------------------------------------------------------- 1 | @app 2 | enhance-blog-template 3 | 4 | @static 5 | prune true 6 | 7 | @plugins 8 | architect/plugin-lambda-invoker 9 | enhance/arc-plugin-enhance 10 | enhance/arc-plugin-styles 11 | create-post-metadata 12 | create-rss-feed 13 | 14 | @events 15 | check-webmention 16 | src jobs/events/check-webmention 17 | incoming-webmention 18 | src jobs/events/incoming-webmention 19 | outgoing-webmention 20 | src jobs/events/outgoing-webmention 21 | 22 | @scheduled 23 | check-rss 24 | rate 1 day 25 | src jobs/scheduled/check-rss 26 | 27 | @enhance-styles 28 | config theme-minimal.json 29 | 30 | @aws 31 | runtime nodejs18.x 32 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # rename to ".env", change the sekret, and do NOT commit 2 | SECRET_PASSWORD=zerocool 3 | -------------------------------------------------------------------------------- /.github/workflows/cla.yml: -------------------------------------------------------------------------------- 1 | name: "CLA Assistant" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened,closed,synchronize] 7 | 8 | permissions: 9 | actions: write 10 | contents: write 11 | pull-requests: write 12 | statuses: write 13 | 14 | jobs: 15 | CLAAssistant: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: "CLA Assistant" 19 | if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' 20 | uses: contributor-assistant/github-action@v2.4.0 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 24 | with: 25 | path-to-signatures: 'signatures/v1/cla.json' 26 | path-to-document: 'https://github.com/enhance-dev/.github/blob/main/CLA.md' 27 | branch: 'main' 28 | allowlist: brianleroux,colepeters,kristoferjoseph,macdonst,ryanbethel,ryanblock,tbeseda 29 | remote-organization-name: enhance-dev 30 | remote-repository-name: .github 31 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | # Push tests pushes; PR tests merges 4 | on: [ push, pull_request ] 5 | 6 | defaults: 7 | run: 8 | shell: bash 9 | 10 | jobs: 11 | # Deploy the build 12 | deploy_staging: 13 | name: Deploy staging 14 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' # Don't run twice for PRs (for now) 15 | runs-on: ubuntu-latest 16 | concurrency: 17 | group: staging_${{ github.repository }} 18 | 19 | steps: 20 | - name: Check out repo 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 16 27 | 28 | - name: Install 29 | run: npm install 30 | 31 | - name: Create blog post metadata 32 | run: npm run posts 33 | 34 | - name: Create RSS feed 35 | run: npm run rss 36 | env: 37 | SITE_URL: ${{ secrets.SITE_URL_STAGING }} 38 | 39 | - name: Deploy to staging 40 | uses: beginner-corp/actions/deploy@main 41 | with: 42 | begin_token: ${{ secrets.BEGIN_TOKEN }} 43 | begin_env_name: staging 44 | channel: 'main' 45 | 46 | # Deploy the build 47 | deploy_production: 48 | name: Deploy production 49 | if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' # Don't run twice for PRs (for now) 50 | runs-on: ubuntu-latest 51 | concurrency: 52 | group: production_${{ github.repository }} 53 | 54 | steps: 55 | - name: Check out repo 56 | uses: actions/checkout@v3 57 | 58 | - name: Set up Node.js 59 | uses: actions/setup-node@v3 60 | with: 61 | node-version: 16 62 | 63 | - name: Install 64 | run: npm install 65 | 66 | - name: Create blog post metadata 67 | run: npm run posts 68 | 69 | - name: Create RSS feed 70 | run: npm run rss 71 | env: 72 | SITE_URL: ${{ secrets.SITE_URL_PRODUCTION }} 73 | 74 | - name: Deploy to production 75 | uses: beginner-corp/actions/deploy@main 76 | with: 77 | begin_token: ${{ secrets.BEGIN_TOKEN }} 78 | begin_env_name: production 79 | channel: 'main' 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | 4 | # Enhance temp files 5 | .enhance/ 6 | shared/enhance-styles 7 | 8 | # Generated assets 9 | public/static.json 10 | shared/static.json 11 | public/bundles/ 12 | public/pages/ 13 | 14 | # Architect CloudFormation 15 | sam.json 16 | sam.yaml 17 | 18 | .DS_Store 19 | # Logs 20 | logs 21 | *.log 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | lerna-debug.log* 26 | .pnpm-debug.log* 27 | 28 | # Diagnostic reports (https://nodejs.org/api/report.html) 29 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | # Directory for instrumented libs generated by jscoverage/JSCover 38 | lib-cov 39 | 40 | # Coverage directory used by tools like istanbul 41 | coverage 42 | *.lcov 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # Bower dependency directory (https://bower.io/) 51 | bower_components 52 | 53 | # node-waf configuration 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | build/Release 58 | 59 | # Dependency directories 60 | node_modules/ 61 | jspm_packages/ 62 | 63 | # Snowpack dependency directory (https://snowpack.dev/) 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | *.tsbuildinfo 68 | 69 | # Optional npm cache directory 70 | .npm 71 | 72 | # Optional eslint cache 73 | .eslintcache 74 | 75 | # Optional stylelint cache 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | .rpt2_cache/ 80 | .rts2_cache_cjs/ 81 | .rts2_cache_es/ 82 | .rts2_cache_umd/ 83 | 84 | # Optional REPL history 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | *.tgz 89 | 90 | # Yarn Integrity file 91 | .yarn-integrity 92 | 93 | # dotenv environment variable files 94 | .env 95 | .env.development.local 96 | .env.test.local 97 | .env.production.local 98 | .env.local 99 | 100 | # parcel-bundler cache (https://parceljs.org/) 101 | .cache 102 | .parcel-cache 103 | 104 | # Next.js build output 105 | .next 106 | out 107 | 108 | # Nuxt.js build / generate output 109 | .nuxt 110 | dist 111 | 112 | # Gatsby files 113 | .cache/ 114 | # Comment in the public line in if your project uses Gatsby and not Next.js 115 | # https://nextjs.org/blog/next-9-1#public-directory-support 116 | # public 117 | 118 | # vuepress build output 119 | .vuepress/dist 120 | 121 | # vuepress v2.x temp and cache directory 122 | .temp 123 | .cache 124 | 125 | # Docusaurus cache and generated files 126 | .docusaurus 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | # TernJS port file 138 | .tern-port 139 | 140 | # Stores VSCode versions used for testing VSCode extensions 141 | .vscode-test 142 | 143 | # yarn v2 144 | .yarn/cache 145 | .yarn/unplugged 146 | .yarn/build-state.yml 147 | .yarn/install-state.gz 148 | .pnp.* 149 | 150 | # Ignore generated files 151 | app/api/posts.json 152 | app/api/rss.br 153 | app/api/rss.xml 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![enhance-type](https://user-images.githubusercontent.com/76308/223593101-1f65f07f-49c4-4a13-9203-4ab4ff72f097.svg) 2 | 3 | # enhance-blog-template 4 | 5 | This is the repo containing the blog template project using Enhance. 6 | 7 | ``` 8 | app 9 | ├── api ..................... data routes 10 | │ ├── admin 11 | │ │ ├── index.mjs ....... load data for admin route 12 | │ │ └── webmentions.mjs . approve webmention 13 | │ ├── posts 14 | │ │ └── $$.mjs .......... load data for individual blog post 15 | │ ├── index.mjs ........... list of blog posts 16 | │ ├── login.mjs ........... verify login 17 | │ ├── logout.mjs .......... log out user 18 | │ ├── rss.mjs ............. rss feed 19 | │ └── webmention.mjs ...... incoming webmention 20 | ├── blog 21 | │ └── posts ............... post files in markdown format 22 | │ └── *.md 23 | ├── elements ................ custom element pure functions 24 | │ └── *.mjs 25 | ├── lib 26 | │ ├── hljs-line-wrapper.mjs 27 | │ └── markdown-class-mappings.mjs 28 | ├── pages ................... file-based routing 29 | │ ├── posts 30 | │ │ └── $$.mjs .......... individual blog post 31 | │ └── index.mjs ........... list of blog posts 32 | └── head.mjs ................ head tag for each page 33 | jobs 34 | ├── events 35 | │ ├── check-webmention .... check new rss item for a webmention 36 | │ ├── incoming-webmention . accept incoming webmention 37 | │ └── outgoing-webmention . send outgoing webmention 38 | └── scheduled ............... scheduled functions 39 | └── check-rss ........... look for new rss items 40 | shared ...................... code shared between app and jobs 41 | ├── posts.mjs ............... database methods for rss items 42 | └── webmentions ............. database methods for webmentions 43 | ``` 44 | 45 | ## Quick Start 46 | 47 | - Clone this repo: 48 | 49 | ```bash 50 | git clone git@github.com:enhance-dev/enhance-blog-template.git 51 | ``` 52 | 53 | - cd into the repo and do an npm install 54 | 55 | ```bash 56 | cd enhance-blog-template 57 | npm install 58 | ``` 59 | - Start the development server. 60 | 61 | ```bash 62 | npm start 63 | ``` 64 | - Open a browser tab to http://localhost:3333 65 | - Start editing your blog 66 | 67 | ## Deploy to Production 68 | 69 | > 🚨 Don't forget to run: `npm run posts` and `npm run rss` to generate the static JSON before deploying. 70 | 71 | - [Install](https://begin.com/docs/getting-started/installing-the-begin-cli) the Begin CLI. 72 | - Login to Begin 73 | 74 | ```bash 75 | begin login 76 | ``` 77 | 78 | - Create your application and staging environment by following the interactive prompts: 79 | 80 | ```bash 81 | begin create 82 | This project doesn't appear to be associated with a Begin app 83 | ? Would you like to create a Begin app based on this project? (Y/n) · true 84 | ? What would you like to name your app? · blog-template 85 | ? What would you like to name your first environment? · staging 86 | Archiving and uploading project to Begin... 87 | Project uploaded, you can now exit this process and check its status with: begin deploy --status 88 | Beginning deployment of 'staging' 89 | Packaging build for deployment 90 | Publishing build to Begin 91 | Build completed! 92 | Deployed 'staging' to: https://blog-template.begin.app 93 | ``` 94 | - [Optional] create a production environment. 95 | 96 | ```bash 97 | begin create --env production 98 | App environment 'production' created at https://blog-template-prod.begin.app 99 | ``` 100 | 101 | ## Styling 102 | 103 | This repo comes preloaded with two basic themes: 104 | 105 | | | Minimal | Elegant | 106 | | - | - | - | 107 | | light | ![Minimal Light](public/images/theme-minimal-light.png) | ![Elegant Light](public/images/theme-elegant-light.png) | 108 | | dark | ![Minimal Dark](public/images/theme-minimal-dark.png) | ![Elegant Dark](public/images/theme-elegant-dark.png) | 109 | 110 | To switch from one theme to another, change the filename referenced under the `@enhance-styles` pragma in the `.arc` file: 111 | 112 | ``` 113 | @enhance-styles 114 | config theme-minimal.json 115 | ``` 116 | 117 | These themes are intended as basic starting points for your own customization. To learn more about how to style your blog, [check out this deep dive on the Begin blog](https://begin.com/blog/posts/2023-04-06-customizing-the-enhance-blog-template)! 118 | 119 | ## Configuring CI/CD 120 | 121 | This repo comes with a GitHub action that will deploy our site to `staging` when there is a commit to the `main` branch and `production` when you tag a release. 122 | 123 | For this to work you must [create a repo secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository) named `BEGIN_TOKEN`. Once you successfully login to Begin using the CLI command `begin login` you can retrieve the value for `BEGIN_TOKEN` in the file `~/.begin/config.json`. Use the value of `access_token` in this file as the value for `BEGIN_TOKEN`. 124 | 125 | Additionally to ensure you `/rss` feed points to the correct environment you will need to create two additional repo secrets. 126 | 127 | - `SITE_URL_STAGING`: set to the url you received when creating the `staging` environment 128 | - `SITE_URL_PRODUCTION`: set to the url you received when creating the `production` environment 129 | 130 | ## Setting up WebMentions 131 | 132 | > Webmention is a W3C recommendation that describes a simple protocol to notify any URL when a website links to it, and for web pages to request notifications when somebody links to them. 133 | 134 | The `enhance-blog-template` supports both incoming and outgoing webmentions. To enable this funcationality, complete the following steps: 135 | 136 | - Add your information to `app/api/h-card.json`. The template will add a `h-card` to each of your blog posts. This will allow remote sites to discover information about you when you link to them. Only include what information you feel comfortable in sharing. A good default would be name, photo and url. For example: 137 | 138 | ```json 139 | { 140 | "name": "Simon MacDonald", 141 | "photo": "https://github.com/macdonst.png", 142 | "url": "https://bookrecs.org" 143 | } 144 | ``` 145 | 146 | For a more detailed example see [app/api/h-card.json.example](./app/api/h-card.json.example). 147 | 148 | - Set a password for the `/admin` route: 149 | 150 | ```bash 151 | begin env create --env staging --name SECRET_PASSWORD --value yoursecretpassword 152 | ``` 153 | 154 | You can use the `/admin` route to approve incoming webmentions. They won't show up under your blog post until you approve them. 155 | -------------------------------------------------------------------------------- /app/api/admin/index.mjs: -------------------------------------------------------------------------------- 1 | import { getWebMentions } from '../../../shared/webmentions.mjs' 2 | 3 | /** @type {import('@enhance/types').EnhanceApiFn} */ 4 | export async function get({ session }) { 5 | const authorized = !!(session.authorized) 6 | if (!authorized) return { location: '/login' } 7 | 8 | const mentions = await getWebMentions() 9 | 10 | return { 11 | json: { authorized, mentions } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/api/admin/webmentions.mjs: -------------------------------------------------------------------------------- 1 | import { getWebMention, upsertWebMention } from '../../../shared/webmentions.mjs' 2 | 3 | /** @type {import('@enhance/types').EnhanceApiFn} */ 4 | export async function post({ session, body }) { 5 | const authorized = !!(session.authorized) 6 | if (!authorized) return { location: '/' } 7 | 8 | const { key, approved } = body 9 | const mention = await getWebMention(key) 10 | mention.approved = approved === 'true' 11 | 12 | await upsertWebMention(mention) 13 | 14 | return { location: '/admin' } 15 | } 16 | -------------------------------------------------------------------------------- /app/api/h-card.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /app/api/h-card.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "name":"Sally Ride", 3 | "honorificPrefix": "Dr.", 4 | "givenName": "Sally", 5 | "additionalName": "K.", 6 | "familyName": "Ride", 7 | "honorificSuffix": "Ph.D.", 8 | "nickname": "sallykride", 9 | "org": "Sally Ride Science", 10 | "photo": "http://example.com/sk.jpg", 11 | "url": "http://sally.example.com", 12 | "email": "mailto:sally@example.com", 13 | "tel": "+1.818.555.1212", 14 | "streetAddress": "123 Main st.", 15 | "locality": "Los Angeles", 16 | "region": "California", 17 | "postalCode": "91316", 18 | "countryName": "U.S.A", 19 | "bday": "1951-05-26", 20 | "category": "physicist", 21 | "note": "First American woman in space." 22 | } 23 | -------------------------------------------------------------------------------- /app/api/index.mjs: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'node:path' 2 | import url from 'node:url' 3 | import { readFileSync } from 'node:fs' 4 | 5 | /** @type {import('@enhance/types').EnhanceApiFn} */ 6 | export async function get(req) { 7 | const here = dirname(url.fileURLToPath(import.meta.url)) 8 | const base = join(here, 'posts.json') 9 | const posts = JSON.parse(readFileSync(base, 'utf-8')) 10 | .reverse() 11 | 12 | const hCardPath = join(here, 'h-card.json') 13 | const hCard = JSON.parse(readFileSync(hCardPath, 'utf-8')) 14 | 15 | const parsedLimit = parseInt(req.query.limit, 10) 16 | const limit = parsedLimit > 0 ? parsedLimit : 20 17 | const parsedOffset = parseInt(req.query.offset, 10) 18 | const offset = parsedOffset >= 0 ? parsedOffset : 0 19 | const total = posts.length 20 | 21 | return { 22 | json: { 23 | posts, 24 | limit, 25 | offset, 26 | total, 27 | hCard, 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/api/login.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@enhance/types').EnhanceApiFn} */ 2 | export async function post({ body }) { 3 | const authorized = body.password === process.env.SECRET_PASSWORD 4 | 5 | return { 6 | location: '/admin', 7 | session: { authorized } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/api/logout.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@enhance/types').EnhanceApiFn} */ 2 | export async function get() { 3 | return { 4 | location: '/', 5 | session: { authorized: false } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/api/posts/$$.mjs: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'node:path' 2 | import url from 'node:url' 3 | import { readFileSync } from 'node:fs' 4 | import { URL } from 'node:url' 5 | import { Arcdown } from 'arcdown' 6 | import HljsLineWrapper from '../../lib/hljs-line-wrapper.mjs' 7 | import { default as defaultClassMapping } from '../../lib/markdown-class-mappings.mjs' 8 | import { getWebMentions } from '../../../shared/webmentions.mjs' 9 | 10 | /** @type {import('@enhance/types').EnhanceApiFn} */ 11 | export async function get(req) { 12 | 13 | // reinvoked each req so no weird regexp caching 14 | const arcdown = new Arcdown({ 15 | pluginOverrides: { 16 | markdownItToc: { 17 | containerClass: 'toc mb2 ml-2', 18 | listType: 'ul', 19 | }, 20 | markdownItClass: defaultClassMapping, 21 | }, 22 | hljs: { 23 | sublanguages: { javascript: ['xml', 'css'] }, 24 | plugins: [new HljsLineWrapper({ className: 'code-line' })], 25 | }, 26 | }) 27 | 28 | const { path: activePath } = req 29 | let docPath = activePath.replace(/^\/?blog\//, '') || 'index' 30 | if (docPath.endsWith('/')) { 31 | docPath += 'index' // trailing slash == index.md file 32 | } 33 | 34 | const docURL = new URL(`../../blog${docPath}.md`, import.meta.url) 35 | const filePath = process.platform !== 'win32' ? docURL.pathname : docURL.pathname.substring(1, docURL.pathname.length) 36 | 37 | let docMarkdown 38 | try { 39 | docMarkdown = readFileSync(filePath, 'utf-8') 40 | } catch (_err) { 41 | console.log(_err) 42 | return { statusCode: 404 } 43 | } 44 | const post = await arcdown.render(docMarkdown) 45 | const mentions = (await getWebMentions()).filter(mention => (mention.targetPath === activePath && mention.approved)) 46 | 47 | let here = dirname(url.fileURLToPath(import.meta.url)) 48 | let hCardPath = join(here, '..', 'h-card.json') 49 | let hCard = JSON.parse(readFileSync(hCardPath, 'utf-8')) 50 | 51 | return { 52 | json: { 53 | post, 54 | mentions, 55 | hCard 56 | }, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/api/rss.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import url from 'node:url' 3 | import { readFileSync } from 'node:fs' 4 | 5 | export async function get(req) { 6 | let here = path.dirname(url.fileURLToPath(import.meta.url)) 7 | 8 | let acceptEncoding = (req.headers && req.headers['accept-encoding'] || 9 | req.headers && req.headers['Accept-Encoding']) 10 | let returnCompressed = acceptEncoding && acceptEncoding.includes('br') 11 | 12 | let resp = { 13 | statusCode: 200, 14 | headers: { 15 | 'content-type': 'application/rss+xml; charset=UTF-8', 16 | }, 17 | } 18 | 19 | if (returnCompressed) { 20 | let postsFilePath = path.join(here, 'rss.br') 21 | resp.body = readFileSync(postsFilePath, 'utf-8') 22 | resp.isBase64Encoded = true 23 | resp.headers['content-encoding'] = 'br' 24 | } else { 25 | let postsFilePath = path.join(here, 'rss.xml') 26 | resp.body = readFileSync(postsFilePath, 'utf-8') 27 | } 28 | 29 | return resp 30 | } 31 | -------------------------------------------------------------------------------- /app/api/webmention.mjs: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions" 2 | 3 | /** @type {import('@enhance/types').EnhanceApiFn} */ 4 | export async function post({ body }) { 5 | const { target, source } = body 6 | 7 | // validate incoming webmention 8 | const errors = [] 9 | if (!target) { 10 | errors.push('missing_target') 11 | } else if (!target.startsWith('http')) { 12 | errors.push('invalid_target') 13 | } 14 | if (!source) { 15 | errors.push('missing_source') 16 | } else if (!source.startsWith('http')) { 17 | errors.push('invalid_source') 18 | } 19 | if (target && source && target === source) { 20 | errors.push('invalid: source must not match target') 21 | } 22 | 23 | if (errors.length > 0) { 24 | console.log(`Discovered ${errors.length} errors, returning 400:`, errors) 25 | return { 26 | code: 400, 27 | json: { errors }, 28 | } 29 | } 30 | 31 | // publish event to store incoming webmention 32 | await arc.events.publish({ 33 | name: 'incoming-webmention', 34 | payload: { source, target }, 35 | }) 36 | 37 | return { 38 | code: 202, 39 | text: 'accepted', 40 | } 41 | } 42 | 43 | export async function get() { 44 | return { 45 | code: 405, 46 | text: 'plz send POST', 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /app/blog/posts/2023-02-23-pale-blue-dot.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pale Blue Dot 3 | description: The Pale Blue Dot is a photograph of planet Earth taken in 1990 by the Voyager 1 spacecraft from a record distance of more than 6 billion kilometers (4 billion miles) from Earth. At Carl Sagan’s urging, the spacecraft, which was leaving the Solar System, was commanded by NASA to turn its camera around and to take a photograph of Earth across a great expanse of space. 4 | published: "February 23, 2023" 5 | --- 6 | 7 | ![pale-blue-dot](/_public/images/pale-blue-dot.jpg) 8 | 9 | Look again at that dot. That's here. That's home. That's us. On it everyone you love, everyone you know, everyone you ever heard of, every human being who ever was, lived out their lives. The aggregate of our joy and suffering, thousands of confident religions, ideologies, and economic doctrines, every hunter and forager, every hero and coward, every creator and destroyer of civilization, every king and peasant, every young couple in love, every mother and father, hopeful child, inventor and explorer, every teacher of morals, every corrupt politician, every "superstar," every "supreme leader," every saint and sinner in the history of our species lived there--on a mote of dust suspended in a sunbeam. 10 | 11 | 12 | The Earth is a very small stage in a vast cosmic arena. Think of the rivers of blood spilled by all those generals and emperors so that, in glory and triumph, they could become the momentary masters of a fraction of a dot. Think of the endless cruelties visited by the inhabitants of one corner of this pixel on the scarcely distinguishable inhabitants of some other corner, how frequent their misunderstandings, how eager they are to kill one another, how fervent their hatreds. 13 | 14 | 15 | Our posturings, our imagined self-importance, the delusion that we have some privileged position in the Universe, are challenged by this point of pale light. Our planet is a lonely speck in the great enveloping cosmic dark. In our obscurity, in all this vastness, there is no hint that help will come from elsewhere to save us from ourselves. 16 | 17 | 18 | The Earth is the only world known so far to harbor life. There is nowhere else, at least in the near future, to which our species could migrate. Visit, yes. Settle, not yet. Like it or not, for the moment the Earth is where we make our stand. 19 | 20 | 21 | It has been said that astronomy is a humbling and character-building experience. There is perhaps no better demonstration of the folly of human conceits than this distant image of our tiny world. To me, it underscores our responsibility to deal more kindly with one another, and to preserve and cherish the pale blue dot, the only home we've ever known. 22 | 23 | -------------------------------------------------------------------------------- /app/blog/posts/2023-02-28-we-choose.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "We Choose to go to the Moon" 3 | description: There is no strife, no prejudice, no national conflict in outer space as yet. Its hazards are hostile to us all. Its conquest deserves the best of all mankind, and its opportunity for peaceful cooperation many never come again. But why, some say, the moon? Why choose this as our goal? And they may well ask why climb the highest mountain? Why, 35 years ago, fly the Atlantic? Why does Rice play Texas? 4 | published: "February 28, 2023" 5 | --- 6 | 7 | ![the-moon](/_public/images/the-moon.jpg) 8 | 9 | There is no strife, no prejudice, no national conflict in outer space as yet. Its hazards are hostile to us all. Its conquest deserves the best of all mankind, and its opportunity for peaceful cooperation many never come again. But why, some say, the moon? Why choose this as our goal? And they may well ask why climb the highest mountain? Why, 35 years ago, fly the Atlantic? Why does Rice play Texas? 10 | 11 | 12 | We choose to go to the moon. We choose to go to the moon in this decade and do the other things, not because they are easy, but because they are hard, because that goal will serve to organize and measure the best of our energies and skills, because that challenge is one that we are willing to accept, one we are unwilling to postpone, and one which we intend to win, and the others, too. 13 | 14 | 15 | It is for these reasons that I regard the decision last year to shift our efforts in space from low to high gear as among the most important decisions that will be made during my incumbency in the office of the Presidency. 16 | 17 | 18 | In the last 24 hours we have seen facilities now being created for the greatest and most complex exploration in man's history. We have felt the ground shake and the air shattered by the testing of a Saturn C-1 booster rocket, many times as powerful as the Atlas which launched John Glenn, generating power equivalent to 10,000 automobiles with their accelerators on the floor. We have seen the site where the F-1 rocket engines, each one as powerful as all eight engines of the Saturn combined, will be clustered together to make the advanced Saturn missile, assembled in a new building to be built at Cape Canaveral as tall as a 48 story structure, as wide as a city block, and as long as two lengths of this field. 19 | -------------------------------------------------------------------------------- /app/blog/posts/2023-03-01-element-styling-reference.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Element Styling Reference 3 | description: "A post using a range of HTML elements which you can use as a styling reference." 4 | published: "March 1, 2023" 5 | --- 6 | 7 | This post uses a wide range of HTML elements, which you can use a styling reference. For example, this is a plain paragraph, and it features things like emphasized text and even text of strong importance. But perhaps you’d rather see things in an unordered list? 8 | 9 | - Here's a list item. 10 | - And a second. 11 | - And, finally, a third list item, which uses a fair bit more text, so that you can see what your list item looks like with line wrapping. 12 | 13 | Or perhaps you'd rather something a little more orderly: 14 | 15 | 1. This is an ordered list. 16 | 1. Its items are preceded by ordinal characters like numbers and letters. 17 | 1. Use this list when the order of the items is important. For example, the ingredients in a recipe would be written using an unordered list, but the cooking instructions would be written using an ordered list. Now this line wraps, too. 18 | 19 | For those times when you need to present a list of terms with accompanying details for each, you can use a description list: 20 | 21 |
22 |
Description list
23 |
An HTML element used to enumerate a group of terms and accompanying details pertaining to those terms
24 | 25 |
Description term
26 |
An HTML element used to denote a term within a descripton list
27 | 28 |
Description detail
29 |
An HTML element used to denote some details for an accompanying term within a description list
30 |
31 | 32 | ## A second level heading 33 | 34 | Sometimes, you might wish to feature a quote from someone. For that, of course, you would use the `blockquote` element (and hey, there's an inline code element)! 35 | 36 | > The general struggle for existence of animate beings is not a struggle for raw materials — these, for organisms, are air, water and soil, all abundantly available — nor for energy which exists in plenty in any body in the form of heat, but a struggle for [negative] entropy, which becomes available through the transition of energy from the hot sun to the cold earth. 37 | > 38 | > — Ludwig Boltzmann 39 | 40 | ### A third level heading 41 | 42 | For those of you who might be using this blog to write about code, you'll want to pay attention to the styling of your code blocks. For example: 43 | 44 | ```javascript 45 | export default function MyCodeBlock({ html, state }) { 46 | const { attrs } = state 47 | const { lang } = attrs 48 | 49 | return html` 50 |
51 |       
52 |         
53 |       
54 |     
55 | ` 56 | } 57 | ``` 58 | 59 | For information on how to change the code highlighting colours, see the post [How to use this template](/posts/2023-03-08-how-to-use). (Hey, there's a link!) 60 | 61 | #### A fourth level heading 62 | 63 | If you want to display tabular data, there is, of course, the `table` element! Here's one detailing some of the HTML elements used to build tables: 64 | 65 | | HTML Element | Common name | Role | 66 | |--------------|-------------|------| 67 | | `` | Table | Declare a table | 68 | | `` | Table header | Define a set of rows making up the header of the table | 69 | | `` | Table body | Define the body of the table | 71 | | `` | Table row | Define a row in the table | 72 | | `
` | Table header cell | Define a cell within the header of the table | 70 | | `
` | Table data cell | Define a cell in the table | 73 | 74 | * * * 75 | 76 | Sometimes it's useful to separate flow content with a horizontal rule, or `
` element. Look just above this text to see one in action! 77 | 78 | ## That's it! 79 | 80 | If you think of any other elements you’d like to see showcased in this example post, feel free to [let us know on Discord](https://enhance.dev/discord), or [file an issue on GitHub!](https://github.com/enhance-dev/enhance-blog-template/issues) 81 | -------------------------------------------------------------------------------- /app/blog/posts/2023-03-08-how-to-use.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: How to use this template 3 | description: "Tips on how to customize this blog template." 4 | published: "March 8, 2023" 5 | --- 6 | 7 | While we've done our best to make this blog template usable right out of the box, we assume you will want to customize it to suit your own needs. The following is a set of spots you may want to update before deploying to production. 8 | 9 | ## Blog Posts 10 | 11 | You'll probably want to clear out all the files in `app/blog/posts` since you didn't write them. Any file with the extension `.md` will automatically be added to your blog. The filename should follow the format `YYYY-MM-DD-title.md` (e.g. `2023-03-07-new-post.md`). 12 | 13 | In order for the file to be correctly processed it needs to include some frontmatter. 14 | 15 | ```yaml 16 | --- 17 | title: The title 18 | description: A description of your post 19 | published: The date published in the format "Month Day, Year" (e.g. March 7, 2023) 20 | --- 21 | ``` 22 | 23 | Then the rest of your post can be any valid markdown including fenced code blocks. For more info on how to add additional languages to the syntax highlighting check out the documentation for [Arcdown](https://github.com/architect/arcdown). 24 | 25 | ## Styling 26 | 27 | Styles for this template are applied in the following places: 28 | 29 | 1. `styleguide.json`: Dark and light colours are defined here, as are the font stacks to be used on headings and body text. System font stacks are used by default; you can find some great alternatives over at [Modern Font Stacks](https://modernfontstacks.com/). 30 | 1. `public/css/global.css`: This stylesheet applies some basic styles at the global level. 31 | 1. `public/css/a11y-dark.min.css`: This stylesheet applies syntax highlighting to code blocks. Feel free to swap this out with another [HighlightJS theme](https://highlightjs.org/static/demo/) of your choosing (and update the link to this stylesheet in your `head.mjs` file). 32 | 1. `app/lib/markdown-class-mappings.mjs`: This file exports an object of HTML element names matched to arrays of classes from [Enhance’s utility class system](https://enhance.dev/docs/learn/concepts/styling/utility-classes). When your markdown files are converted to HTML, these classes will be attached to the respective HTML elements. 33 | 1. ` 20 |
21 | 22 | 23 | 24 |
25 | ` 26 | } 27 | 28 | return html` 29 | 68 | 69 |
70 | 71 |

💬 Webmentions

72 | 73 | ${mentions?.length ? ` 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ${mentions.map(m => { 86 | const sourceUrl = new URL(m.source) 87 | return /* html */` 88 | 89 | 90 | 91 | 92 | 93 | 99 | 100 | ` 101 | }).join('')} 102 | 103 |
SourceTargetAuthorFoundStatus
${m.sourceTitle} (${sourceUrl.hostname})${m.targetPath}${m.sourceAuthor}${m.found} 94 | ${typeof m.approved === 'boolean' 95 | ? m.approved ? 'Approved' : 'Rejected' 96 | : form(m) 97 | } 98 |
104 | ` : ` 105 |

No mentions yet!

106 | `} 107 | ` 108 | } 109 | -------------------------------------------------------------------------------- /app/elements/blog-card.mjs: -------------------------------------------------------------------------------- 1 | export default function BlogPost({ html, state }) { 2 | const { attrs, store } = state 3 | const { key } = attrs 4 | const { href, frontmatter } = store.posts[key] 5 | const { description, published, readtime, title } = 6 | frontmatter 7 | return html` 8 | 18 | 19 |
20 |
21 |

${title}

22 |

${description}

23 |

24 | ${published}
25 | ${readtime} to read 26 |

27 |
28 |
29 |
30 | ` 31 | } 32 | -------------------------------------------------------------------------------- /app/elements/blog-pagination-button.mjs: -------------------------------------------------------------------------------- 1 | export default function BlogPaginationButton({ html, state }) { 2 | const { attrs, store } = state 3 | const { limit = 20 } = store 4 | const { index, label } = attrs 5 | 6 | const booleanAttr = (attrs, attr) => 7 | Object.keys(attrs).includes(attr) ? attr : '' 8 | const active = booleanAttr(attrs, 'active') 9 | 10 | return html` 11 |
  • 21 | ${!active 22 | ? ` 26 | ${label} 27 | ` 28 | : `
    29 | ${label} 30 |
    `} 31 |
  • 32 | ` 33 | } 34 | -------------------------------------------------------------------------------- /app/elements/blog-pagination.mjs: -------------------------------------------------------------------------------- 1 | export default function BlogPagination({ html, state }) { 2 | const { store } = state 3 | const { limit = 20, offset = 0, total = 1 } = store 4 | 5 | if (limit >= total) { 6 | return `` 7 | } 8 | 9 | const currentIndex = Math.floor(offset / limit) 10 | const totalPages = Math.ceil(total / limit) 11 | 12 | const buttons = new Array(totalPages) 13 | .fill('') 14 | .map((_, index) => index) // populate array values with indexes 15 | .filter(pageIndex => pageIndex >= currentIndex - 2 && pageIndex <= currentIndex + 2) // trim to 2 indexes before and after active index 16 | .map( 17 | pageIndex => 18 | `` 23 | ) 24 | 25 | const prevButton = currentIndex !== 0 26 | ? `` 27 | : '' 28 | 29 | const nextButton = currentIndex + 1 !== totalPages 30 | ? `` 31 | : '' 32 | 33 | const firstButton = currentIndex > 3 34 | ? '' 35 | : '' 36 | 37 | const lastButton = currentIndex < totalPages - 3 38 | ? `` 39 | : '' 40 | 41 | return html` 42 | 58 | 69 | ` 70 | } 71 | -------------------------------------------------------------------------------- /app/elements/blog-posts.mjs: -------------------------------------------------------------------------------- 1 | export default function BlogPosts({ html, state }) { 2 | const { store } = state 3 | const { posts = [], offset, limit } = store 4 | 5 | const cards = posts 6 | .slice(offset, offset + limit) 7 | .map((o, i) => `post`) 8 | .join('') 9 | 10 | return html` 11 |
    12 | ${cards} 13 |
    14 | ` 15 | } 16 | -------------------------------------------------------------------------------- /app/elements/h-card.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@enhance/types').EnhanceElemFn} */ 2 | export default function HCard({ html, state: { attrs } }) { 3 | const { 4 | name, 5 | honorificPrefix, 6 | givenName, 7 | additionalName, 8 | familyName, 9 | sortString, 10 | honorificSuffix, 11 | email, 12 | logo, 13 | photo, 14 | url, 15 | uid, 16 | category, 17 | adr, 18 | postOfficeBox, 19 | streetAddress, 20 | extendedAddress, 21 | locality, 22 | region, 23 | postalCode, 24 | countryName, 25 | label, 26 | geo, 27 | latitude, 28 | longitude, 29 | altitude, 30 | tel, 31 | note, 32 | bday, 33 | key, 34 | org, 35 | jobTitle, 36 | role, 37 | impp, 38 | sex, 39 | genderIdentity, 40 | anniversary } = attrs 41 | return html` 42 |
    43 | ${name ? `${name}` : '' } 44 | ${honorificPrefix ? `${honorificPrefix}` : '' } 45 | ${givenName ? `${givenName}` : '' } 46 | ${additionalName ? `${additionalName}` : '' } 47 | ${familyName ? `${familyName}` : '' } 48 | ${sortString ? `${sortString}` : '' } 49 | ${honorificSuffix ? `${honorificSuffix}` : '' } 50 | ${email ? `e-mail` : '' } 51 | ${logo ? `` : '' } 52 | ${photo ? `` : '' } 53 | ${url ? `url` : '' } 54 | ${uid ? `uid` : '' } 55 | ${category ? `${category}` : '' } 56 | ${adr ? `${adr}` : '' } 57 | ${postOfficeBox ? `${postOfficeBox}` : '' } 58 | ${streetAddress ? `${streetAddress}` : '' } 59 | ${extendedAddress ? `${extendedAddress}` : '' } 60 | ${locality ? `${locality}` : '' } 61 | ${region ? `${region}` : '' } 62 | ${postalCode ? `${postalCode}` : '' } 63 | ${countryName ? `${countryName}` : '' } 64 | ${label ? `${label}` : '' } 65 | ${geo ? `${geo}` : '' } 66 | ${latitude ? `${latitude}` : '' } 67 | ${longitude ? `${longitude}` : '' } 68 | ${altitude ? `${altitude}` : '' } 69 | ${tel ? `${tel}` : '' } 70 | ${note ? `${note}` : '' } 71 | ${bday ? ` birthday` : '' } 72 | ${key ? `key` : '' } 73 | ${org ? `${org}` : '' } 74 | ${jobTitle ? `${jobTitle}` : '' } 75 | ${role ? `${role}` : '' } 76 | ${impp ? `impp` : '' } 77 | ${sex ? `${sex}` : '' } 78 | ${genderIdentity ? `${genderIdentity}` : '' } 79 | ${anniversary ? ` anniversary` : '' } 80 | 81 | 82 | 83 |
    84 | ` 85 | } 86 | -------------------------------------------------------------------------------- /app/elements/my-h-card.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@enhance/types').EnhanceElemFn} */ 2 | export default function MyHCard({ html, state: { store } }) { 3 | const { hCard } = store 4 | const attributes = Object.keys(hCard).map(key => `${key}="${hCard[key]}" `).join('') 5 | return html` 6 | 7 | ` 8 | } 9 | -------------------------------------------------------------------------------- /app/elements/site-container.mjs: -------------------------------------------------------------------------------- 1 | export default function BlogContainer({ html }) { 2 | return html` 3 | 16 | 17 | 18 | ` 19 | } 20 | -------------------------------------------------------------------------------- /app/elements/site-footer.mjs: -------------------------------------------------------------------------------- 1 | export default function SiteFooter ({ html }) { 2 | const me = null // Ex. 'https://social.example.com/@username' 3 | return html` 4 |
    5 | ${me ? `

    6 | Mastodon 7 |

    ` : ''} 8 |

    9 | RSS 10 |

    11 |
    12 | ` 13 | } 14 | -------------------------------------------------------------------------------- /app/elements/site-header.mjs: -------------------------------------------------------------------------------- 1 | export default function SiteHeader ({ html }) { 2 | return html` 3 | 8 |
    9 |

    10 | 11 | Enhance Blog Template 12 | 13 |

    14 |

    15 | A subtitle for this blog 16 |

    17 |
    18 | ` 19 | } 20 | -------------------------------------------------------------------------------- /app/elements/site-layout.mjs: -------------------------------------------------------------------------------- 1 | export default function SiteLayout ({ html }) { 2 | return html` 3 | 4 | 5 | 6 | 7 | 8 | ` 9 | } 10 | -------------------------------------------------------------------------------- /app/elements/submit-button.mjs: -------------------------------------------------------------------------------- 1 | export default function SubmitButton ({ html }) { 2 | return html` 3 | 20 | 23 | ` 24 | } 25 | -------------------------------------------------------------------------------- /app/elements/text-input.mjs: -------------------------------------------------------------------------------- 1 | export default function TextInput ({ html, state }) { 2 | const { attrs } = state 3 | const { autocomplete='off', id, label, name, placeholder = '', type } = attrs 4 | 5 | return html` 6 | 33 | 39 | ` 40 | } 41 | -------------------------------------------------------------------------------- /app/elements/webmentions-list.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@enhance/types').EnhanceElemFn} */ 2 | export default function ({ html, state: { store } }) { 3 | const { mentions } = store 4 | return html` 5 | 57 | 58 |
    59 |

    Mentions

    60 |
      61 | ${mentions.map(m => ` 62 |
    • 63 |
      64 |
      65 | ${m.sourceAuthorImage 66 | ? `` 67 | : `` 68 | } 69 |

      70 | ${m.sourceAuthor} 71 | ${new Date(m.created).toLocaleDateString()} 72 |

      73 |
      74 | ${m.summary ? `
      ${m.summary}
      ` : ''} 75 | 76 |
      77 | 78 | 82 | 83 |
    • `.trim(), 84 | ).join('')} 85 |
    86 | ` 87 | } 88 | -------------------------------------------------------------------------------- /app/head.mjs: -------------------------------------------------------------------------------- 1 | import { getLinkTag } from '@enhance/arc-plugin-styles/get-styles' 2 | 3 | export default function Head() { 4 | const siteUrl = process.env.SITE_URL || 'http://localhost:3333' 5 | const me = null // Ex. 'https://social.example.com/@username' 6 | return` 7 | 8 | 9 | 10 | Enhance Blog Template 11 | 12 | ${getLinkTag()} 13 | 14 | 15 | 16 | ${me ? `` : ''} 17 | 18 | ` 19 | } 20 | -------------------------------------------------------------------------------- /app/lib/hljs-line-wrapper.mjs: -------------------------------------------------------------------------------- 1 | export default class { 2 | constructor(options) { 3 | this.className = options.className 4 | } 5 | 6 | 'after:highlight'(result) { 7 | 8 | const tokens = [] 9 | 10 | const safelyTagged = result.value.replace( 11 | /(]+>)|(<\/span>)|(\n)/, 12 | (match) => { 13 | if (match === '\n') { 14 | return `${''.repeat(tokens.length)}\n${tokens.join('')}` 15 | } 16 | 17 | if (match === '') { 18 | tokens.pop() 19 | } else { 20 | tokens.push(match) 21 | } 22 | 23 | return match 24 | } 25 | ) 26 | 27 | result.value = safelyTagged 28 | .split('\n') 29 | .reduce((result, line, index, lines) => { 30 | const lastLine = index + 1 === lines.length 31 | if (!(lastLine && line.length === 0)) { 32 | result.push( 33 | `${line}` 34 | ) 35 | } 36 | return result 37 | }, []) 38 | .join('\n') 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/lib/markdown-class-mappings.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | h2: ['text2', 'text3-lg', 'font-heading', 'tracking-1', 'font-bold', 'mbe0', 'mbs4', 'leading1'], 3 | h3: ['text1', 'text2-lg', 'font-heading', 'tracking-1', 'font-bold', 'mbe0', 'mbs4', 'leading1'], 4 | h4: ['text0', 'text1-lg', 'font-heading', 'tracking-1', 'font-bold', 'mbe0', 'mbs4', 'leading1'], 5 | p: ['mbs-1', 'mbe0'], 6 | ol: ['mb2', 'pis4'], 7 | ul: ['mb2', 'pis4'], 8 | blockquote: ['mb2', 'p0'], 9 | strong: ['font-bold'], 10 | a: ['underline'], 11 | table: [ 12 | 'w-full', 13 | 'mbe1', 14 | 'text-1', 15 | 'text0-lg', 16 | ], 17 | th: ['text-start'], 18 | img: ['pb0', 'block', 'm-auto'], 19 | } 20 | -------------------------------------------------------------------------------- /app/pages/admin/index.html: -------------------------------------------------------------------------------- 1 | 4 | 5 |
    6 |

    Admin

    7 | 8 | 9 | 10 | 15 |
    16 |
    17 | -------------------------------------------------------------------------------- /app/pages/index.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@enhance/types').EnhanceElemFn} */ 2 | export default function ({ html, state }) { 3 | const { store } = state 4 | const { limit, offset, total } = store 5 | 6 | return html` 7 | 8 | 9 |
    10 | 11 | 17 |
    18 |
    19 | ` 20 | } 21 | -------------------------------------------------------------------------------- /app/pages/login.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |
    4 | 8 | Login 9 |
    10 |
    11 |
    12 | -------------------------------------------------------------------------------- /app/pages/posts/$$.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@enhance/types').EnhanceElemFn} */ 2 | export default function ({ html, state }) { 3 | const { store } = state 4 | const { post, mentions } = store 5 | const { frontmatter } = post 6 | const { description = '', published = '', title = '' } = frontmatter 7 | 8 | return html` 9 | 14 | 15 |
    16 |

    ${title}

    17 |

    ${published}

    18 |
    ${post.html}
    19 | 20 | 21 |
    22 | ${mentions?.length ? '' : ''} 23 |
    24 | ` 25 | } 26 | -------------------------------------------------------------------------------- /jobs/events/check-webmention/index.mjs: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions" 2 | import { upsertPost } from "@architect/shared/posts.mjs" 3 | import * as cheerio from 'cheerio' 4 | 5 | export const handler = arc.events.subscribe(async (event) => { 6 | const { link, content } = event 7 | 8 | // Check to see if any of our outgoing links are to sites 9 | // that accept web mentions 10 | const $ = cheerio.load(content) 11 | $('a').each(async (idx, el) => { 12 | const target = el?.attribs?.href 13 | if (target?.startsWith('http')) { 14 | await arc.events.publish({ 15 | name: 'outgoing-webmention', 16 | payload: { 17 | source: link, 18 | target 19 | }, 20 | }) 21 | } 22 | }) 23 | 24 | // We've processed this post so never look at it again 25 | await upsertPost({ link }) 26 | 27 | return 28 | }) 29 | -------------------------------------------------------------------------------- /jobs/events/incoming-webmention/index.mjs: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions" 2 | import { upsertWebMention } from "@architect/shared/webmentions.mjs" 3 | import { mf2 } from 'microformats-parser' 4 | 5 | export const handler = arc.events.subscribe(async (event) => { 6 | const { target, source } = event 7 | const targetUrl = new URL(target) 8 | const sourceUrl = new URL(source) 9 | const newMention = { 10 | id: `target:${targetUrl.pathname}::source:${sourceUrl.hostname}${sourceUrl.pathname}`, 11 | created: new Date().toISOString(), 12 | source, 13 | target, 14 | targetPath: targetUrl.pathname, 15 | } 16 | 17 | const sourceReq = await fetch(sourceUrl.href) 18 | if (sourceReq.ok) { 19 | const sourceBody = await sourceReq.text() 20 | 21 | // search body for target URL 22 | newMention.found = sourceBody.indexOf(targetUrl.href) > -1 23 | const { items } = mf2(sourceBody, { baseUrl: sourceUrl.href }) 24 | newMention.items = items 25 | const hEntry = items.find((i) => i.type?.includes('h-entry')) 26 | 27 | // get author name 28 | if ( 29 | hEntry?.properties?.author && 30 | Array.isArray(hEntry.properties.author) 31 | ) { 32 | if (typeof hEntry.properties.author[0] === 'string') { 33 | newMention.sourceAuthor = hEntry.properties.author[0] 34 | } else if (hEntry.properties.author[0].value) { 35 | newMention.sourceAuthor = hEntry.properties.author[0].value 36 | } 37 | } 38 | 39 | // overwrite author name with h-card name if available 40 | if (hEntry?.children) { 41 | const hCard = hEntry.children.find((i) => i.type?.includes('h-card')) 42 | if (hCard?.properties?.name && Array.isArray(hCard.properties.name)) { 43 | newMention.sourceAuthor = hCard.properties.name[0] 44 | } 45 | if (hCard?.properties?.photo && Array.isArray(hCard.properties.photo)) { 46 | newMention.sourceAuthorImage = hCard.properties.photo[0] 47 | } 48 | } 49 | 50 | // get source title 51 | if (hEntry?.properties?.name && Array.isArray(hEntry.properties.name)) { 52 | newMention.sourceTitle = hEntry.properties.name[0] 53 | } 54 | 55 | // get summary 56 | if (hEntry?.properties?.summary && Array.isArray(hEntry.properties.summary)) { 57 | newMention.summary = hEntry.properties.summary[0] 58 | } 59 | } else { 60 | newMention.error = { 61 | message: `source URL ${sourceUrl.href} returned ${sourceReq.status}`, 62 | } 63 | } 64 | 65 | // save result 66 | await upsertWebMention(newMention) 67 | 68 | return 69 | }) 70 | -------------------------------------------------------------------------------- /jobs/events/outgoing-webmention/index.mjs: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions" 2 | import * as cheerio from 'cheerio' 3 | import li from 'li' 4 | import { URL } from 'node:url' 5 | 6 | async function getWebmentionUrl(targetUrl) { 7 | let webmention = null 8 | const targetRes = await fetch(targetUrl.href, {redirect: "follow"}) 9 | if (targetRes.ok) { 10 | const linkHeader = targetRes.headers.has('link') ? targetRes.headers.get('link') : targetRes.headers.get('Link') 11 | if (linkHeader) { 12 | const links = li.parse(linkHeader) 13 | webmention = links.webmention || links['http://webmention.org/'] 14 | if (webmention) { 15 | return webmention.startsWith('http') ? webmention : new URL(webmention, targetUrl.origin) 16 | } 17 | } 18 | 19 | const text = await targetRes.text() 20 | const $ = cheerio.load(text) 21 | let found = false 22 | $('link, a').each((idx, el) => { 23 | const rels = (el?.attribs?.rel || '').split(' ') 24 | if (!found && (el?.attribs?.rel === 'webmention' || rels.includes('webmention'))) { 25 | if (el?.attribs?.href) { 26 | webmention = el.attribs.href.startsWith('http') ? el.attribs.href : new URL(el.attribs.href, targetUrl.origin) 27 | } else { 28 | webmention = targetUrl.href 29 | } 30 | found = true 31 | } 32 | }) 33 | } 34 | return webmention 35 | } 36 | 37 | export const handler = arc.events.subscribe(async (event) => { 38 | const { target } = event 39 | // Check for web mention url at remote site 40 | const webmentionUrl = await getWebmentionUrl(new URL(target)) 41 | 42 | // if the remote site accepts webmentions, send 43 | if (webmentionUrl) { 44 | const response = await fetch(webmentionUrl, { 45 | method: 'POST', 46 | body: new URLSearchParams(event), 47 | }) 48 | 49 | let message = `<${webmentionUrl}>: ` 50 | if (response.ok) { 51 | const contentType = response.headers.get('content-type') 52 | message += contentType?.startsWith('application/json') 53 | ? await response.json() 54 | : await response.text() 55 | } else { 56 | message += `Error ${response.status}: ${response.statusText}` 57 | } 58 | 59 | console.log(message) 60 | } 61 | 62 | return 63 | }) 64 | -------------------------------------------------------------------------------- /jobs/scheduled/check-rss/index.mjs: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions" 2 | import { getPosts } from "@architect/shared/posts.mjs" 3 | import { parseStringPromise } from "xml2js" 4 | 5 | export async function handler () { 6 | // Get processed posts 7 | const posts = await getPosts() 8 | 9 | // Read RSS feed 10 | const siteUrl = process.env.SITE_URL || 'http://localhost:3333' 11 | const rssUrl = new URL(`${siteUrl}/rss`) 12 | const response = await fetch(rssUrl.href) 13 | const text = await response.text() 14 | const result = await parseStringPromise(text) 15 | const items = result?.rss?.channel[0]?.item || [] 16 | const filteredItems = items.filter(item => !posts.find(post => post?.link === item.link[0])) 17 | 18 | // Send new posts to be checked for webmentions 19 | // eslint-disable-next-line no-undef 20 | await Promise.all(filteredItems.map(async (item) => { 21 | await arc.events.publish({ 22 | name: 'check-webmention', 23 | payload: { 24 | link: item.link[0], 25 | content: item['content:encoded'][0] 26 | }, 27 | }) 28 | })) 29 | 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright 2023 Beginner Web Corp 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enhance-blog-template", 3 | "version": "0.0.2", 4 | "scripts": { 5 | "enhance": "enhance", 6 | "posts": "node ./src/plugins/create-post-metadata.js", 7 | "rss": "node ./src/plugins/create-rss-feed.js", 8 | "start": "npx enhance dev", 9 | "lint": "eslint ./app/**/*.mjs --fix" 10 | }, 11 | "engines": { 12 | "node": ">=18" 13 | }, 14 | "devDependencies": { 15 | "@architect/plugin-lambda-invoker": "^1.2.0", 16 | "@aws-sdk/client-apigatewaymanagementapi": "^3.451.0", 17 | "@aws-sdk/client-dynamodb": "^3.451.0", 18 | "@aws-sdk/client-s3": "^3.451.0", 19 | "@aws-sdk/client-sns": "^3.451.0", 20 | "@aws-sdk/client-sqs": "^3.451.0", 21 | "@aws-sdk/client-ssm": "^3.451.0", 22 | "@aws-sdk/lib-dynamodb": "^3.451.0", 23 | "@enhance/cli": "^1.0.8", 24 | "@enhance/types": "^0.6.1", 25 | "eslint": "^8.44.0" 26 | }, 27 | "eslintConfig": { 28 | "env": { 29 | "node": true 30 | }, 31 | "extends": "eslint:recommended", 32 | "rules": { 33 | "indent": [ 34 | "error", 35 | 2 36 | ] 37 | }, 38 | "ignorePatterns": [], 39 | "parserOptions": { 40 | "sourceType": "module", 41 | "ecmaVersion": 2022 42 | } 43 | }, 44 | "dependencies": { 45 | "@architect/functions": "^7.0.0", 46 | "@begin/data": "^4.0.2", 47 | "@enhance/arc-plugin-enhance": "^11.0.0", 48 | "@enhance/arc-plugin-styles": "^5.0.5", 49 | "arcdown": "^2.2.1", 50 | "cheerio": "^1.0.0-rc.12", 51 | "feed": "^4.2.2", 52 | "gray-matter": "^4.0.3", 53 | "li": "^1.3.0", 54 | "microformats-parser": "^1.4.1", 55 | "reading-time": "^1.5.0", 56 | "xml2js": "^0.5.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /prefs.arc: -------------------------------------------------------------------------------- 1 | @sandbox 2 | livereload true 3 | -------------------------------------------------------------------------------- /public/css/a11y-dark.min.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! 2 | Theme: a11y-dark 3 | Author: @ericwbailey 4 | Maintainer: @ericwbailey 5 | 6 | Based on the Tomorrow Night Eighties theme: https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow-night-eighties.css 7 | */.hljs{background:#2b2b2b;color:#f8f8f2}.hljs-comment,.hljs-quote{color:#d4d0ab}.hljs-deletion,.hljs-name,.hljs-regexp,.hljs-selector-class,.hljs-selector-id,.hljs-tag,.hljs-template-variable,.hljs-variable{color:#ffa07a}.hljs-built_in,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-type{color:#f5ab35}.hljs-attribute{color:gold}.hljs-addition,.hljs-bullet,.hljs-string,.hljs-symbol{color:#abe338}.hljs-section,.hljs-title{color:#00e0e0}.hljs-keyword,.hljs-selector-tag{color:#dcc6e0}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}@media screen and (-ms-high-contrast:active){.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-comment,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-quote,.hljs-string,.hljs-symbol,.hljs-type{color:highlight}.hljs-keyword,.hljs-selector-tag{font-weight:700}} 8 | -------------------------------------------------------------------------------- /public/css/global.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: var(--color-light); 3 | color: var(--color-dark); 4 | } 5 | 6 | body { 7 | -webkit-font-smoothing: initial; 8 | -moz-osx-font-smoothing: initial; 9 | } 10 | 11 | blockquote { 12 | border-width: 1px 1px 1px 4px; 13 | border-style: solid; 14 | border-color: var(--color-dark); 15 | } 16 | 17 | pre { 18 | padding: 0.5rem 1rem; 19 | overflow-x: scroll; 20 | } 21 | 22 | code { 23 | font-family: ui-monospace, 'IBM Plex Mono', 'Fira Mono', 'Source Code Pro', Menlo, Monaco, monospace; 24 | font-size: 0.9em; 25 | } 26 | 27 | hr { 28 | border-block-start: 1px solid var(--color-dark); 29 | margin-block: 1.5rem; 30 | } 31 | 32 | /*
    ,
    , and
    elements don't have markdown syntax; 33 | * therefore their styles are defined here rather than markdown-class-mappings.mjs 34 | */ 35 | dl { 36 | padding-inline-start: var(--space-4); 37 | } 38 | 39 | dt { 40 | font-weight: 700; 41 | } 42 | 43 | dd { 44 | margin-block-end: 1.5em; 45 | } 46 | 47 | .font-body { 48 | font-family: var(--font-body); 49 | } 50 | 51 | .font-heading { 52 | font-family: var(--font-heading); 53 | } 54 | 55 | /* Dark mode overrides */ 56 | @media (prefers-color-scheme: dark) { 57 | html { 58 | background: var(--color-dark); 59 | color: var(--color-light); 60 | } 61 | 62 | blockquote { 63 | border-color: var(--color-light); 64 | } 65 | 66 | hr { 67 | border-color: var(--color-light); 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/pale-blue-dot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enhance-dev/enhance-blog-template/56aa85b9311c573734222bda7d757ee872d4f5a8/public/images/pale-blue-dot.jpg -------------------------------------------------------------------------------- /public/images/the-moon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enhance-dev/enhance-blog-template/56aa85b9311c573734222bda7d757ee872d4f5a8/public/images/the-moon.jpg -------------------------------------------------------------------------------- /public/images/theme-elegant-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enhance-dev/enhance-blog-template/56aa85b9311c573734222bda7d757ee872d4f5a8/public/images/theme-elegant-dark.png -------------------------------------------------------------------------------- /public/images/theme-elegant-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enhance-dev/enhance-blog-template/56aa85b9311c573734222bda7d757ee872d4f5a8/public/images/theme-elegant-light.png -------------------------------------------------------------------------------- /public/images/theme-minimal-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enhance-dev/enhance-blog-template/56aa85b9311c573734222bda7d757ee872d4f5a8/public/images/theme-minimal-dark.png -------------------------------------------------------------------------------- /public/images/theme-minimal-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enhance-dev/enhance-blog-template/56aa85b9311c573734222bda7d757ee872d4f5a8/public/images/theme-minimal-light.png -------------------------------------------------------------------------------- /shared/posts.mjs: -------------------------------------------------------------------------------- 1 | import data from '@begin/data' 2 | 3 | const deletePost = async function (key) { 4 | await data.destroy({ table: 'posts', key }) 5 | return { key } 6 | } 7 | 8 | const upsertPost = async function (post) { 9 | return data.set({ table: 'posts', ...post }) 10 | } 11 | 12 | const getPost = async function (key) { 13 | return data.get({ table: 'posts', key }) 14 | } 15 | 16 | const getPosts = async function () { 17 | const databasePageResults = await data.page({ 18 | table: 'posts', 19 | limit: 25 20 | }) 21 | 22 | let posts = [] 23 | for await (let databasePageResult of databasePageResults) { 24 | for (let post of databasePageResult) { 25 | delete post.table 26 | posts.push(post) 27 | } 28 | } 29 | 30 | return posts 31 | } 32 | 33 | export { 34 | deletePost, 35 | getPost, 36 | getPosts, 37 | upsertPost 38 | } 39 | -------------------------------------------------------------------------------- /shared/webmentions.mjs: -------------------------------------------------------------------------------- 1 | import data from '@begin/data' 2 | 3 | const deleteWebMention = async function (key) { 4 | await data.destroy({ table: 'webmentions', key }) 5 | return { key } 6 | } 7 | 8 | const upsertWebMention = async function (webmention) { 9 | return data.set({ table: 'webmentions', ...webmention }) 10 | } 11 | 12 | const getWebMention = async function (key) { 13 | return data.get({ table: 'webmentions', key }) 14 | } 15 | 16 | const getWebMentions = async function () { 17 | const databasePageResults = await data.page({ 18 | table: 'webmentions', 19 | limit: 25 20 | }) 21 | 22 | let webmentions = [] 23 | for await (let databasePageResult of databasePageResults) { 24 | for (let webmention of databasePageResult) { 25 | delete webmention.table 26 | webmentions.push(webmention) 27 | } 28 | } 29 | 30 | return webmentions 31 | } 32 | 33 | export { 34 | deleteWebMention, 35 | getWebMention, 36 | getWebMentions, 37 | upsertWebMention 38 | } 39 | -------------------------------------------------------------------------------- /src/plugins/create-post-metadata.js: -------------------------------------------------------------------------------- 1 | if (!process.env.ARC_ENV) { 2 | process.env.ARC_ENV = 'testing' 3 | } 4 | const matter = require('gray-matter'); 5 | const { join, parse } = require('path') // eslint-disable-line 6 | const base = join(__dirname, '..', '..', 'app', 'blog', 'posts') 7 | 8 | async function generate () { 9 | const { readdir, readFile, writeFile } = require('fs/promises') // eslint-disable-line 10 | const readingTime = require('reading-time') // eslint-disable-line 11 | 12 | const posts = await readdir(base) 13 | 14 | async function render (path) { 15 | const file = await readFile(join(base, path), 'utf8') 16 | const result = matter(file) 17 | result.data.readtime = `${Math.floor(readingTime(file).minutes)} mins` 18 | return result.data 19 | } 20 | 21 | async function getData (filePath) { 22 | const frontmatter = await render(filePath) 23 | return { 24 | href: `/posts/${parse(filePath).name}`, 25 | frontmatter 26 | } 27 | } 28 | 29 | const cards = [] 30 | for (let path of posts) { 31 | let card = await getData(path) 32 | cards.push(card) 33 | } 34 | 35 | let postsJson = join(__dirname, '..', '..', 'app', 'api', 'posts.json') 36 | await writeFile(postsJson, JSON.stringify(cards, null, 2)) 37 | } 38 | 39 | module.exports = { 40 | sandbox: { 41 | start: generate, 42 | watcher: async (params) => { 43 | let { filename } = params 44 | if (!filename.includes(base) || !filename.endsWith('.md')) { 45 | return 46 | } 47 | await generate(params) 48 | } 49 | } 50 | } 51 | 52 | if (require.main === module) { 53 | (async function () { 54 | try { 55 | generate() 56 | } 57 | catch (err) { 58 | console.log(err) 59 | } 60 | })() 61 | } 62 | -------------------------------------------------------------------------------- /src/plugins/create-rss-feed.js: -------------------------------------------------------------------------------- 1 | const { join, extname } = require('path') // eslint-disable-line 2 | const { brotliCompressSync } = require('zlib') 3 | const base = join(__dirname, '..', '..', 'app', 'blog', 'posts') 4 | 5 | function getHostname() { 6 | return process.env.SITE_URL ? process.env.SITE_URL : 'http://localhost:3333' 7 | } 8 | 9 | async function generate () { 10 | const { readdir, readFile, writeFile } = require('fs/promises') // eslint-disable-line 11 | const { Feed } = await import('feed') 12 | const { Arcdown } = await import('arcdown') 13 | 14 | const arcdown = new Arcdown({}) 15 | 16 | const posts = await readdir(base) 17 | 18 | const hostname = getHostname() 19 | 20 | async function render(path) { 21 | const file = await readFile(`${base}/${path}`, 'utf8') 22 | let result = await arcdown.render(file) 23 | return { content: result.html, frontmatter: result.frontmatter } 24 | } 25 | 26 | async function getData(pathName) { 27 | const { content, frontmatter } = await render(pathName) 28 | const filename = pathName.substring( 29 | 0, 30 | pathName.length - extname(pathName).length 31 | ) 32 | return { 33 | href: `${filename}`, 34 | content, 35 | frontmatter, 36 | } 37 | } 38 | 39 | const items = ( 40 | await Promise.all( // eslint-disable-line 41 | posts 42 | .sort((a, b) => (a.post < b.post ? 1 : -1)) 43 | .map(async (post) => await getData(post)) 44 | ).catch(function(err) { 45 | console.log(err.message); // some coding error in handling happened 46 | }) 47 | ) 48 | 49 | const feed = new Feed({ 50 | title: 'Enhance Blog Template', 51 | description: "My blog description.", 52 | id: hostname, 53 | link: hostname, 54 | language: 'en', 55 | copyright: `All rights reserved ${new Date().getFullYear()}, My Company`, 56 | generator: hostname + ' via Feed for Node.js', 57 | author: { 58 | name: 'My Company', 59 | link: hostname, 60 | }, 61 | }) 62 | 63 | for (const post of items) { 64 | let { frontmatter, content, href } = post 65 | let { title = '', description = '', published = '', author = '', category = '' } = frontmatter 66 | let link = `${hostname}/posts/${href}` 67 | let image = frontmatter.image ? `${hostname}${frontmatter.image}` : null 68 | let authorArray = author ? [ { name: author }] : [] 69 | let categoryArray = category ? category.split(',').map(str => { return { name: str.trim() } }) : [] 70 | feed.addItem({ 71 | title, 72 | id: link, 73 | link, 74 | description, 75 | content, 76 | date: new Date(published), 77 | author: authorArray, 78 | image, 79 | category: categoryArray 80 | }) 81 | } 82 | 83 | let feedXml = feed.rss2() 84 | let rssFeed = join(__dirname, '..', '..', 'app', 'api', 'rss.xml') 85 | await writeFile(rssFeed, feedXml) 86 | let rssBrotli = join(__dirname, '..', '..', 'app', 'api', 'rss.br') 87 | await writeFile(rssBrotli, Buffer.from(brotliCompressSync(feedXml)).toString('base64')) 88 | } 89 | 90 | module.exports = { 91 | sandbox: { 92 | start: generate, 93 | watcher: async (params) => { 94 | let { filename } = params 95 | if (!filename.includes(base) || !filename.endsWith('.md')) { 96 | return 97 | } 98 | await generate(params) 99 | } 100 | } 101 | } 102 | 103 | if (require.main === module) { 104 | (async function () { 105 | try { 106 | generate() 107 | } 108 | catch (err) { 109 | console.log(err) 110 | } 111 | })() 112 | } 113 | -------------------------------------------------------------------------------- /theme-elegant.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeScale": { 3 | "steps": 8, 4 | "baseMin": 16, 5 | "baseMax": 18, 6 | "viewportMin": 320, 7 | "viewportMax": 1500, 8 | "scaleMin": 1.2, 9 | "scaleMax": 1.25 10 | }, 11 | "spaceScale": { 12 | "steps": 8, 13 | "baseMin": 16, 14 | "baseMax": 18, 15 | "viewportMin": 320, 16 | "viewportMax": 1500, 17 | "scaleMin": 1.2, 18 | "scaleMax": 1.25 19 | }, 20 | "properties": { 21 | "align-heading": "center", 22 | "color-dark": "hsl(204deg 30% 30%)", 23 | "color-light": "hsl(260deg 30% 94%)", 24 | "font-body": "Avenir, 'Avenir Next LT Pro', Montserrat, Corbel, 'URW Gothic', source-sans-pro, sans-serif;", 25 | "font-heading": "Didot, 'Bodoni MT', 'Noto Serif Display', 'URW Palladio L', P052, Sylfaen, serif;" 26 | }, 27 | "queries": { 28 | "lg": "48em" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /theme-minimal.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeScale": { 3 | "steps": 8, 4 | "baseMin": 16, 5 | "baseMax": 18, 6 | "viewportMin": 320, 7 | "viewportMax": 1500, 8 | "scaleMin": 1.2, 9 | "scaleMax": 1.25 10 | }, 11 | "spaceScale": { 12 | "steps": 8, 13 | "baseMin": 16, 14 | "baseMax": 18, 15 | "viewportMin": 320, 16 | "viewportMax": 1500, 17 | "scaleMin": 1.2, 18 | "scaleMax": 1.25 19 | }, 20 | "properties": { 21 | "align-heading": "start", 22 | "color-dark": "hsl(200deg 5% 15%)", 23 | "color-light": "hsl(24deg 10% 97.5%)", 24 | "font-body": "system-ui, sans-serif", 25 | "font-heading": "system-ui, sans-serif" 26 | }, 27 | "queries": { 28 | "lg": "48em" 29 | } 30 | } 31 | --------------------------------------------------------------------------------