├── .dockerignore ├── .github ├── release-drafter.yml └── workflows │ ├── release-drafter.yml │ ├── release.yml │ └── xlog.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.go ├── cmd ├── assets │ ├── dubai-font.css │ ├── dubai-font │ │ ├── DubaiW23-Bold.woff │ │ └── DubaiW23-Regular.woff │ ├── index.js │ ├── package.json │ ├── styles.scss │ ├── webpack.config.js │ └── yarn.lock └── xlog │ └── xlog.go ├── command.go ├── docker-compose.yaml ├── docs ├── .cache │ ├── 1316c89f817afdf85038ad3a02698a3a4757263c7e28f0d83c34c387d9d5088a.json │ ├── 66f5fcbba4e9ef12e70b5b685c478e62f2fe659fbc9438ca7fd7582d22342d0e.json │ ├── 718ff3b88f310e3dfadb357db10a1d6a2a973217656adbf35b4be9075e57fd59.json │ ├── 9bfa931273abab8cd69e213e5657059e8107cc359d011f3893433a1911b59a71.json │ ├── dadbd4f3a88b7ac925c9afb369104ff8b429ed32fe02ec2725b94c789610585a.json │ └── de4b0df00d5d7b3c5e9159bfae73447229522578195227cbee2f21c0f8ea461f.json ├── 404.md ├── API.md ├── Assets.md ├── Bulma.md ├── Dependencies.md ├── Features Test.md ├── Github.md ├── Go package.md ├── Installation.md ├── Readonly.md ├── Security.md ├── Upgrading.md ├── Usage.md ├── digital gardening.md ├── extensions.md ├── goldmark.md ├── header-particles.html ├── header.html ├── helper.md ├── official extensions.md └── public │ ├── custom.png │ ├── d32ac848ea161f9b384ed2ed81d657e3f150bcd3aa355a75741b95c76b873898.avif │ ├── puzzle.png │ ├── shortcode.png │ ├── sprout.png │ ├── static.png │ └── website.png ├── each.go ├── each_test.go ├── events.go ├── extensions.go ├── extensions ├── activitypub │ └── activitypub.go ├── all │ └── all.go ├── autolink │ └── autolink.go ├── autolink_pages │ ├── Autolink pages.md │ ├── autolink_pages.go │ ├── extension.go │ ├── node.go │ ├── parser.go │ ├── renderer.go │ └── templates │ │ └── backlinks.html ├── blocks │ ├── blocks.go │ ├── public │ │ └── blocks.css │ └── templates │ │ ├── book.html │ │ ├── github-user.html │ │ ├── hero.html │ │ └── person.html ├── custom_widget │ └── main.go ├── date │ ├── Date and Calendar.md │ ├── extension.go │ ├── handlers.go │ ├── links.go │ ├── node.go │ ├── parser.go │ ├── renderer.go │ └── templates │ │ ├── calendar.html │ │ └── date.html ├── disqus │ └── main.go ├── editor │ └── extension.go ├── embed │ └── embed.go ├── file_operations │ ├── delete.go │ ├── file_operations.go │ ├── rename.go │ └── templates │ │ └── rename-form.html ├── frontmatter │ └── extension.go ├── github │ ├── api.go │ └── main.go ├── gpg │ ├── commands.go │ ├── gpg.go │ ├── handlers.go │ ├── page.go │ └── page_source.go ├── hashtags │ ├── hashtags.go │ └── templates │ │ ├── hashtag-pages-grid.html │ │ ├── hashtag-pages.html │ │ ├── related-hashtags-pages.html │ │ ├── tag.html │ │ └── tags.html ├── heading │ └── renderer.go ├── hotreload │ ├── hotreload.go │ └── script.html ├── html │ └── html.go ├── images │ ├── columnize.go │ ├── extension.go │ ├── node.go │ └── renderer.go ├── link_preview │ ├── link_preview.go │ └── templates │ │ └── link-preview.html ├── manifest │ ├── manifest.go │ └── templates │ │ └── manifest.html ├── mathjax │ ├── extension.go │ ├── js │ │ ├── output │ │ │ └── chtml │ │ │ │ └── fonts │ │ │ │ └── woff-v2 │ │ │ │ ├── MathJax_AMS-Regular.woff │ │ │ │ ├── MathJax_Calligraphic-Bold.woff │ │ │ │ ├── MathJax_Calligraphic-Regular.woff │ │ │ │ ├── MathJax_Fraktur-Bold.woff │ │ │ │ ├── MathJax_Fraktur-Regular.woff │ │ │ │ ├── MathJax_Main-Bold.woff │ │ │ │ ├── MathJax_Main-Italic.woff │ │ │ │ ├── MathJax_Main-Regular.woff │ │ │ │ ├── MathJax_Math-BoldItalic.woff │ │ │ │ ├── MathJax_Math-Italic.woff │ │ │ │ ├── MathJax_Math-Regular.woff │ │ │ │ ├── MathJax_SansSerif-Bold.woff │ │ │ │ ├── MathJax_SansSerif-Italic.woff │ │ │ │ ├── MathJax_SansSerif-Regular.woff │ │ │ │ ├── MathJax_Script-Regular.woff │ │ │ │ ├── MathJax_Size1-Regular.woff │ │ │ │ ├── MathJax_Size2-Regular.woff │ │ │ │ ├── MathJax_Size3-Regular.woff │ │ │ │ ├── MathJax_Size4-Regular.woff │ │ │ │ ├── MathJax_Typewriter-Regular.woff │ │ │ │ ├── MathJax_Vector-Bold.woff │ │ │ │ ├── MathJax_Vector-Regular.woff │ │ │ │ └── MathJax_Zero.woff │ │ └── tex-chtml-full.js │ ├── nodes.go │ ├── parser.go │ └── renderer.go ├── mermaid │ ├── main.go │ └── script.html ├── opengraph │ └── opengraph.go ├── pandoc │ └── pandoc.go ├── photos │ ├── photos.go │ ├── properties.go │ └── templates │ │ ├── photo.html │ │ ├── photos-grid.html │ │ └── photos.html ├── recent │ ├── recent.go │ └── templates │ │ └── recent.html ├── rss │ └── main.go ├── rtl │ └── rtl.go ├── search │ ├── search.go │ └── templates │ │ ├── search-form.html │ │ ├── search-result.html │ │ └── search.html ├── shortcode │ ├── ShortCode.md │ ├── extension.go │ ├── node.go │ ├── parser.go │ ├── parser_test.go │ ├── renderer.go │ ├── shortcode.go │ └── transformer.go ├── sitemap │ └── sitemap.go ├── sql_table │ ├── extension.go │ └── js │ │ └── sql_table.html ├── star │ └── star.go ├── toc │ ├── extension.go │ └── templates │ │ └── toc.html ├── todo │ ├── extension.go │ ├── handler.go │ └── renderer.go └── upload_file │ ├── extension.go │ ├── record_audio.go │ ├── record_camera.go │ ├── record_screen.go │ ├── screenshot.go │ ├── templates │ ├── record-audio.html │ ├── record-camera.html │ ├── record-screen.html │ ├── screenshot.html │ └── upload.html │ └── upload.go ├── flags.go ├── fs.go ├── go.mod ├── go.sum ├── handlers.go ├── helpers.go ├── helpers_test.go ├── index.md ├── markdown_fs.go ├── page.go ├── page_source.go ├── preprocessor.go ├── property.go ├── public ├── assets │ ├── DubaiW23-Bold.woff │ ├── DubaiW23-Regular.woff │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.ttf │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.ttf │ ├── fa-solid-900.woff2 │ ├── inter-all-100-normal.woff │ ├── inter-all-200-normal.woff │ ├── inter-all-300-normal.woff │ ├── inter-all-400-normal.woff │ ├── inter-all-500-normal.woff │ ├── inter-all-600-normal.woff │ ├── inter-all-700-normal.woff │ ├── inter-all-800-normal.woff │ ├── inter-all-900-normal.woff │ ├── inter-cyrillic-100-normal.woff2 │ ├── inter-cyrillic-200-normal.woff2 │ ├── inter-cyrillic-300-normal.woff2 │ ├── inter-cyrillic-400-normal.woff2 │ ├── inter-cyrillic-500-normal.woff2 │ ├── inter-cyrillic-600-normal.woff2 │ ├── inter-cyrillic-700-normal.woff2 │ ├── inter-cyrillic-800-normal.woff2 │ ├── inter-cyrillic-900-normal.woff2 │ ├── inter-cyrillic-ext-100-normal.woff2 │ ├── inter-cyrillic-ext-200-normal.woff2 │ ├── inter-cyrillic-ext-300-normal.woff2 │ ├── inter-cyrillic-ext-400-normal.woff2 │ ├── inter-cyrillic-ext-500-normal.woff2 │ ├── inter-cyrillic-ext-600-normal.woff2 │ ├── inter-cyrillic-ext-700-normal.woff2 │ ├── inter-cyrillic-ext-800-normal.woff2 │ ├── inter-cyrillic-ext-900-normal.woff2 │ ├── inter-greek-100-normal.woff2 │ ├── inter-greek-200-normal.woff2 │ ├── inter-greek-300-normal.woff2 │ ├── inter-greek-400-normal.woff2 │ ├── inter-greek-500-normal.woff2 │ ├── inter-greek-600-normal.woff2 │ ├── inter-greek-700-normal.woff2 │ ├── inter-greek-800-normal.woff2 │ ├── inter-greek-900-normal.woff2 │ ├── inter-greek-ext-100-normal.woff2 │ ├── inter-greek-ext-200-normal.woff2 │ ├── inter-greek-ext-300-normal.woff2 │ ├── inter-greek-ext-400-normal.woff2 │ ├── inter-greek-ext-500-normal.woff2 │ ├── inter-greek-ext-600-normal.woff2 │ ├── inter-greek-ext-700-normal.woff2 │ ├── inter-greek-ext-800-normal.woff2 │ ├── inter-greek-ext-900-normal.woff2 │ ├── inter-latin-100-normal.woff2 │ ├── inter-latin-200-normal.woff2 │ ├── inter-latin-300-normal.woff2 │ ├── inter-latin-400-normal.woff2 │ ├── inter-latin-500-normal.woff2 │ ├── inter-latin-600-normal.woff2 │ ├── inter-latin-700-normal.woff2 │ ├── inter-latin-800-normal.woff2 │ ├── inter-latin-900-normal.woff2 │ ├── inter-latin-ext-100-normal.woff2 │ ├── inter-latin-ext-200-normal.woff2 │ ├── inter-latin-ext-300-normal.woff2 │ ├── inter-latin-ext-400-normal.woff2 │ ├── inter-latin-ext-500-normal.woff2 │ ├── inter-latin-ext-600-normal.woff2 │ ├── inter-latin-ext-700-normal.woff2 │ ├── inter-latin-ext-800-normal.woff2 │ ├── inter-latin-ext-900-normal.woff2 │ ├── inter-vietnamese-100-normal.woff2 │ ├── inter-vietnamese-200-normal.woff2 │ ├── inter-vietnamese-300-normal.woff2 │ ├── inter-vietnamese-400-normal.woff2 │ ├── inter-vietnamese-500-normal.woff2 │ ├── inter-vietnamese-600-normal.woff2 │ ├── inter-vietnamese-700-normal.woff2 │ ├── inter-vietnamese-800-normal.woff2 │ └── inter-vietnamese-900-normal.woff2 ├── htmx.min.js ├── logo.png └── style.css ├── renderer.go ├── server.go ├── starred.md ├── template.go ├── templates ├── commands.html ├── emoji-favicon.html ├── layout.html ├── navbar.html ├── page.html ├── pages-grid.html └── pages.html ├── tutorials ├── Create your own digital garden on Github.md ├── Creating a site.md ├── Custom installation.md ├── Generate static website.md └── Hello world extension.md └── widgets.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .git/ 3 | .github/ 4 | docs/ 5 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Maintenance' 14 | label: 'chore' 15 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 16 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 17 | version-resolver: 18 | major: 19 | labels: 20 | - 'major' 21 | minor: 22 | labels: 23 | - 'minor' 24 | patch: 25 | labels: 26 | - 'patch' 27 | default: patch 28 | template: | 29 | ## Changes 30 | 31 | $CHANGES 32 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | types: [opened, reopened, synchronize] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | update_release_draft: 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: release-drafter/release-drafter@v6.1.0 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - v* 5 | 6 | env: 7 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | # create-release: 13 | # name: Create Release 14 | # runs-on: ubuntu-latest 15 | # steps: 16 | # - name: Checkout code 17 | # uses: actions/checkout@master 18 | # - name: Create Release 19 | # id: create_release 20 | # uses: actions/create-release@latest 21 | # with: 22 | # tag_name: ${{ github.ref }} 23 | # release_name: ${{ github.ref }} 24 | # draft: false 25 | # prerelease: false 26 | releases-matrix: 27 | # needs: create-release 28 | name: Release Go Binary 29 | runs-on: ubuntu-latest 30 | strategy: 31 | matrix: 32 | goos: [linux, windows, darwin] 33 | goarch: ["386", amd64, arm64] 34 | exclude: 35 | - goarch: "386" 36 | goos: darwin 37 | - goarch: arm64 38 | goos: windows 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: wangyoucao577/go-release-action@v1.40 42 | with: 43 | github_token: ${{ secrets.GITHUB_TOKEN }} 44 | goos: ${{ matrix.goos }} 45 | goarch: ${{ matrix.goarch }} 46 | project_path: "./cmd/xlog" 47 | binary_name: "xlog" 48 | extra_files: LICENSE README.md 49 | docker: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout 53 | uses: actions/checkout@v4 54 | - name: Login to Docker Hub 55 | uses: docker/login-action@v2 56 | with: 57 | registry: "ghcr.io" 58 | username: ${{ github.actor }} 59 | password: ${{ secrets.GITHUB_TOKEN }} 60 | - name: Extract metadata (tags, labels) for Docker 61 | id: meta 62 | uses: docker/metadata-action@v4 63 | with: 64 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 65 | - name: Build and push 66 | uses: docker/build-push-action@v3 67 | with: 68 | context: . 69 | push: true 70 | file: ./Dockerfile 71 | tags: ${{ steps.meta.outputs.tags }} 72 | labels: ${{ steps.meta.outputs.labels }} 73 | -------------------------------------------------------------------------------- /.github/workflows/xlog.yml: -------------------------------------------------------------------------------- 1 | name: Xlog 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: restore timestamps 32 | uses: chetan/git-restore-mtime-action@v1 33 | 34 | - name: Set up Go 35 | uses: actions/setup-go@v5 36 | with: 37 | check-latest: true 38 | 39 | - name: Build 40 | run: | 41 | go run ./cmd/xlog \ 42 | --build . \ 43 | --sitename "Xlog" \ 44 | --theme "light" \ 45 | --notfoundpage "docs/404" \ 46 | --custom.head=docs/header.html \ 47 | --sitemap.domain=xlog.emadelsaid.com \ 48 | --rss.domain xlog.emadelsaid.com \ 49 | --activitypub.domain=xlog.emadelsaid.com \ 50 | --activitypub.username=app \ 51 | --activitypub.summary="Xlog is a static site generator for digital gardening written in Go. It serves markdown files as HTML and allows editing files online. It focuses on enriching markdown files and surfacing implicit links between pages." \ 52 | --og.domain xlog.emadelsaid.com \ 53 | --github.url https://github.com/emad-elsaid/xlog/edit/master 54 | rm *.md 55 | chmod -R 0777 . 56 | 57 | 58 | - name: Upload GitHub Pages artifact 59 | uses: actions/upload-pages-artifact@v3.0.1 60 | with: 61 | path: . 62 | 63 | deploy: 64 | environment: 65 | name: github-pages 66 | url: ${{ steps.deployment.outputs.page_url }} 67 | runs-on: ubuntu-latest 68 | needs: build 69 | steps: 70 | - name: Deploy to GitHub Pages 71 | id: deployment 72 | uses: actions/deploy-pages@v4 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.versions 2 | node_modules 3 | yarn-error.log 4 | public/ignored.js 5 | cmd/assets/node_modules -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine as builder 2 | 3 | WORKDIR /app 4 | COPY go.mod ./ 5 | COPY go.sum ./ 6 | RUN go mod download 7 | COPY ./ ./ 8 | RUN go build -o xlog ./cmd/xlog 9 | 10 | FROM alpine as final 11 | COPY --from=builder /app/xlog /bin/xlog 12 | 13 | CMD ["xlog", "-bind", "0.0.0.0:3000", "-source", "/files"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Emad Elsaid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | XLog 2 | ========= 3 | 4 | :vhs: Xlog is a static site generator for digital gardening written in Go. It serves markdown files as HTML and allows editing files online. It focuses on enriching markdown files and surfacing implicit links between pages. 5 | 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/emad-elsaid/xlog)](https://goreportcard.com/report/github.com/emad-elsaid/xlog) [![GoDoc](https://godoc.org/github.com/emad-elsaid/xlog?status.svg)](https://godoc.org/github.com/emad-elsaid/xlog) 7 | 8 |

9 | 10 | 11 | # Documentation 12 | 13 | * [Documentation](https://xlog.emadelsaid.com/) 14 | * [Installation](https://xlog.emadelsaid.com/docs/Installation/) 15 | * [Usage](https://xlog.emadelsaid.com/docs/Usage/) 16 | * [Generating static site](https://xlog.emadelsaid.com/tutorials/Creating%20a%20site) 17 | * [Overriding Assets](https://xlog.emadelsaid.com/docs/Assets) 18 | * [Extensions](https://xlog.emadelsaid.com/docs/extensions/) 19 | * [Writing Your Own Extension](https://xlog.emadelsaid.com/tutorials/Hello%20world%20extension/) 20 | 21 | # License 22 | 23 | Xlog is released under [MIT license](LICENSE) 24 | -------------------------------------------------------------------------------- /cmd/assets/dubai-font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Dubai W23'; 3 | font-style: normal; 4 | font-weight: bold; 5 | src: local('Dubai W23 Bold Regular'), url('/dubai-font/DubaiW23-Bold.woff') format('woff'); 6 | unicode-range: U+0600-06FF; 7 | } 8 | 9 | 10 | @font-face { 11 | font-family: 'Dubai W23'; 12 | font-style: normal; 13 | font-weight: regular; 14 | src: local('Dubai W23 Regular Regular'), url('/dubai-font/DubaiW23-Regular.woff') format('woff'); 15 | unicode-range: U+0600-06FF; 16 | } 17 | -------------------------------------------------------------------------------- /cmd/assets/dubai-font/DubaiW23-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/cmd/assets/dubai-font/DubaiW23-Bold.woff -------------------------------------------------------------------------------- /cmd/assets/dubai-font/DubaiW23-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/cmd/assets/dubai-font/DubaiW23-Regular.woff -------------------------------------------------------------------------------- /cmd/assets/index.js: -------------------------------------------------------------------------------- 1 | require('./styles.scss'); 2 | -------------------------------------------------------------------------------- /cmd/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xlog", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/emad-elsaid/xlog.git", 6 | "author": "Emad Elsaid ", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "webpack --mode production", 10 | "watch": "webpack --watch --mode production" 11 | }, 12 | "dependencies": { 13 | "@fontsource/inter": "^4.5.14", 14 | "@fortawesome/fontawesome-free": "^6.2.1", 15 | "bulma": "^1.0.0", 16 | "css-loader": "^6.7.3", 17 | "extract-text-webpack-plugin": "^4.0.0-beta.0", 18 | "file-loader": "^6.2.0", 19 | "mini-css-extract-plugin": "^2.7.2", 20 | "resolve-url-loader": "^5.0.0", 21 | "sass-loader": "^13.2.0", 22 | "style-loader": "^3.3.1", 23 | "webpack": "^5.94.0", 24 | "webpack-cli": "^5.0.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cmd/assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | 4 | module.exports = { 5 | entry: './index.js', 6 | output: { 7 | path: path.resolve(__dirname, '../../public'), 8 | filename: 'ignored.js', 9 | assetModuleFilename: 'assets/[name][ext][query]' 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, 15 | type: 'asset/resource', 16 | }, 17 | { 18 | test: /\.s?css$/, 19 | use: [ 20 | MiniCssExtractPlugin.loader, 21 | { 22 | loader: 'css-loader' 23 | }, 24 | { 25 | loader: 'resolve-url-loader', 26 | }, 27 | { 28 | loader: 'sass-loader', 29 | options: { 30 | sourceMap: true 31 | } 32 | } 33 | ] 34 | }] 35 | }, 36 | plugins: [ 37 | new MiniCssExtractPlugin({ 38 | filename: 'style.css' 39 | }), 40 | ] 41 | }; 42 | -------------------------------------------------------------------------------- /cmd/xlog/xlog.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | // Core 5 | "context" 6 | 7 | "github.com/emad-elsaid/xlog" 8 | 9 | // All official extensions 10 | _ "github.com/emad-elsaid/xlog/extensions/all" 11 | ) 12 | 13 | func main() { 14 | xlog.Start(context.Background()) 15 | } 16 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import "html/template" 4 | 5 | // Command defines a structure used for 3 categories of lists: 6 | // 1. Commands for Ctrl+K actions menu 7 | // 2. Quick commands displayed in the default template at the top right of the page 8 | // 3. Links displayed in the navigation bar 9 | // The template decides where and how to display commands. it can choose to use them in a different way than the default template 10 | type Command interface { 11 | // Icon returns the Fontawesome icon class name for the Command 12 | Icon() string 13 | // Name of the command. to be displayed in the list 14 | Name() string 15 | // Attrs a map of attributes to their values 16 | Attrs() map[template.HTMLAttr]any 17 | } 18 | 19 | var commands = []func(Page) []Command{} 20 | 21 | // RegisterCommand registers a new command 22 | func RegisterCommand(c func(Page) []Command) { 23 | commands = append(commands, c) 24 | } 25 | 26 | // Commands return the list of commands for a page. when a page is displayed it 27 | // executes all functions registered with RegisterCommand and collect all 28 | // results in one slice. result can be passed to the view to render the commands 29 | // list 30 | func Commands(p Page) []Command { 31 | cmds := []Command{} 32 | for c := range commands { 33 | cmds = append(cmds, commands[c](p)...) 34 | } 35 | 36 | return cmds 37 | } 38 | 39 | var quickCommands = []func(Page) []Command{} 40 | 41 | func RegisterQuickCommand(c func(Page) []Command) { 42 | quickCommands = append(quickCommands, c) 43 | } 44 | 45 | // QuickCommands return the list of QuickCommands for a page. it executes all functions 46 | // registered with RegisterQuickCommand and collect all results in one slice. result 47 | // can be passed to the view to render the Quick commands list 48 | func QuickCommands(p Page) []Command { 49 | cmds := []Command{} 50 | for c := range quickCommands { 51 | cmds = append(cmds, quickCommands[c](p)...) 52 | } 53 | 54 | return cmds 55 | } 56 | 57 | var links = []func(Page) []Command{} 58 | 59 | // Register a new links function, should return a list of Links 60 | func RegisterLink(l func(Page) []Command) { 61 | links = append(links, l) 62 | } 63 | 64 | // Links returns a list of links for a Page. it executes all functions 65 | // registered with RegisterLink and collect them in one slice. Can be passed to 66 | // the view to render in the footer for example. 67 | func Links(p Page) []Command { 68 | lnks := []Command{} 69 | for l := range links { 70 | lnks = append(lnks, links[l](p)...) 71 | } 72 | return lnks 73 | } 74 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | xlog: 5 | build: . 6 | ports: 7 | - "3000:3000" 8 | volumes: 9 | - ~/.xlog:/files 10 | -------------------------------------------------------------------------------- /docs/.cache/1316c89f817afdf85038ad3a02698a3a4757263c7e28f0d83c34c387d9d5088a.json: -------------------------------------------------------------------------------- 1 | {"URL":"https://www.emadelsaid.com/Why I became a software developer/","Title":" Why I became a software developer | Emad Elsaid","Description":"Why I became a software developer","Image":"https://www.emadelsaid.com/public/IMG_20200217_161802.jpg"} -------------------------------------------------------------------------------- /docs/.cache/66f5fcbba4e9ef12e70b5b685c478e62f2fe659fbc9438ca7fd7582d22342d0e.json: -------------------------------------------------------------------------------- 1 | {"URL":"https://github.com/yuin/goldmark","Title":"GitHub - yuin/goldmark: :trophy: A markdown parser written in Go. Easy to extend, standard(CommonMark) compliant, well structured.","Description":":trophy: A markdown parser written in Go. Easy to extend, standard(CommonMark) compliant, well structured. - yuin/goldmark","Image":"https://opengraph.githubassets.com/50b0f5b6e0281db8eff566c28a58c4fe74cd5a740e36d037711f44e57e98b8e9/yuin/goldmark"} -------------------------------------------------------------------------------- /docs/.cache/718ff3b88f310e3dfadb357db10a1d6a2a973217656adbf35b4be9075e57fd59.json: -------------------------------------------------------------------------------- 1 | {"URL":"https://github.com/emad-elsaid/xlog","Title":"GitHub - emad-elsaid/xlog: 💥 Personal knowledge management application. One binary HTTP server. works in any Markdown directory. autolinks pages, hashtags, auto preview images link, screenshare, screenshot, camera recording and audio recording embedded in the note. and fast search through the KB","Description":"💥 Personal knowledge management application. One binary HTTP server. works in any Markdown directory. autolinks pages, hashtags, auto preview images link, screenshare, screenshot, camera recording ...","Image":"https://repository-images.githubusercontent.com/16852661/85412dd7-4794-400d-b0f5-2557ab658078"} -------------------------------------------------------------------------------- /docs/.cache/9bfa931273abab8cd69e213e5657059e8107cc359d011f3893433a1911b59a71.json: -------------------------------------------------------------------------------- 1 | {"URL":"https://codemirror.net/","Title":"CodeMirror","Description":"In-browser code editor","Image":"http://codemirror.net/style/logo.svg"} -------------------------------------------------------------------------------- /docs/.cache/dadbd4f3a88b7ac925c9afb369104ff8b429ed32fe02ec2725b94c789610585a.json: -------------------------------------------------------------------------------- 1 | {"URL":"https://commonmark.org/","Title":"CommonMark","Description":"","Image":""} -------------------------------------------------------------------------------- /docs/.cache/de4b0df00d5d7b3c5e9159bfae73447229522578195227cbee2f21c0f8ea461f.json: -------------------------------------------------------------------------------- 1 | {"URL":"https://bulma.io/","Title":"https://bulma.io/","Description":"Bulma is a free, open source CSS framework based on Flexbox and built with Sass. It's 100% responsive, fully modular, and available for free.","Image":"https://bulma.io/assets/images/bulma-banner.png"} -------------------------------------------------------------------------------- /docs/404.md: -------------------------------------------------------------------------------- 1 | This usually means that you've typed or followed a bad URL. Occasionally, this can happen if the page has been moved to a new URL (though we try to avoid doing that). 2 | 3 | See [the Wikipedia page on HTTP 404 errors](https://en.wikipedia.org/wiki/HTTP_404) to learn more about HTTP 404 errors in general. 4 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | Xlog is a Go package that has its public API documented and published on https://pkg.go.dev as all Go packages do. Latest documentation can be found here https://pkg.go.dev/github.com/emad-elsaid/xlog -------------------------------------------------------------------------------- /docs/Assets.md: -------------------------------------------------------------------------------- 1 | Xlog serves any files under current directory with exception of markdown files being accessed without `.md` extension and converted to HTML. 2 | 3 | Besides that it serves files from embded files in the program from the core package or extensions. 4 | 5 | # Overriding asset files 6 | 7 | Embded files are the last resort when looking up a file so to override an asset file you just need to put it in the same path in the current directory. that's all. that simple. 8 | 9 | ## CSS 10 | 11 | - Xlog used to have a Go script to compile CSS/SASS to `public/style.css`. 12 | - That changed to depend on Webpack in this commit 38c8171 13 | - So chdir to `cmd/assets` and either build with `yarn build` or watch changes `yarn watch` 14 | -------------------------------------------------------------------------------- /docs/Bulma.md: -------------------------------------------------------------------------------- 1 | A CSS only framework. used to provide a basic look for xlog. 2 | 3 | https://bulma.io/ 4 | 5 | it's embeded in the binary executable at compile time using Go `embed` package. -------------------------------------------------------------------------------- /docs/Dependencies.md: -------------------------------------------------------------------------------- 1 | Compile time dependencies: 2 | 3 | * Go compiler 4 | * Goldmark markdown parser 5 | 6 | Run time dependencies: 7 | 8 | * Bulma CSS framework 9 | -------------------------------------------------------------------------------- /docs/Github.md: -------------------------------------------------------------------------------- 1 | Code is hosted on Github repository. 2 | 3 | 4 | https://github.com/emad-elsaid/xlog 5 | 6 | # Open pull requests 7 | 8 | /github-search-issues repo:emad-elsaid/xlog is:pull-request is:open -------------------------------------------------------------------------------- /docs/Go package.md: -------------------------------------------------------------------------------- 1 | Xlog is distributed as Go package that has a CLI that uses the package and all `xlog/extensions`. documentation of the Go package API can be found on https://pkg.go.dev/github.com/emad-elsaid/xlog -------------------------------------------------------------------------------- /docs/Installation.md: -------------------------------------------------------------------------------- 1 | # Download latest binary 2 | 3 | Github has a release for each xlog version tag. it has binaries built for (Windows, Linux, MacOS) for several architectures. you can download the latest version from this page: https://github.com/emad-elsaid/xlog/releases/latest 4 | 5 | # Using Go 6 | 7 | ```bash 8 | go install github.com/emad-elsaid/xlog/cmd/xlog@latest 9 | ``` 10 | 11 | # From source 12 | 13 | ```bash 14 | git clone git@github.com:emad-elsaid/xlog.git 15 | cd xlog 16 | go run ./cmd/xlog # to run it 17 | go install ./cmd/xlog # to install it to Go bin. 18 | ``` 19 | 20 | # Arch Linux (AUR) 21 | 22 | * Xlog is published to AUR: https://aur.archlinux.org/packages/xlog-git 23 | * Using `yay` for example: 24 | 25 | ```bash 26 | yay -S xlog-git 27 | ``` 28 | 29 | # From source with docker-compose 30 | 31 | ```bash 32 | git clone git@github.com:emad-elsaid/xlog.git 33 | cd xlog 34 | docker-composer build 35 | docker-composer run 36 | ``` 37 | 38 | ```info 39 | Xlog container attach `~/.xlog` as a volume and will write pages to it. 40 | ``` 41 | 42 | # Docker 43 | 44 | Releases are packaged as docker images and pushed to GitHub 45 | 46 | ```bash 47 | docker pull ghcr.io/emad-elsaid/xlog:latest 48 | docker run -p 3000:3000 -v ~/.xlog:/files ghcr.io/emad-elsaid/xlog:latest 49 | ``` -------------------------------------------------------------------------------- /docs/Readonly.md: -------------------------------------------------------------------------------- 1 | Xlog works in 2 modes: 2 | 3 | * Read/Write 4 | * ReadOnly 5 | 6 | By default xlog server works in Read/Write mode where you can edit and delete files. this mode is not for production use. it's meant for local personal use. and this is meant for the first usecase: taking personal notes, local digital gardening. 7 | 8 | ReadOnly mode which can be specified using `--readonly=true` flag. This flag is checked by xlog and extensions to turn of any code that writes to the filesystem. 9 | 10 | /alert don't run xlog server on production server neither in read/write nor in readonly. as it's meant for personal local use. 11 | 12 | Generate static website process will turn on readonly mode automatically. 13 | 14 | Any extensions that writes or modify the filesystem is responsible for checking if `Config.Readonly` global variable is true and make sure that part is not executed. 15 | -------------------------------------------------------------------------------- /docs/Security.md: -------------------------------------------------------------------------------- 1 | Xlog is designed to be accessed by trusted clients inside trusted environments. This means that usually it is not a good idea to expose the Xlog instance directly to the internet or, in general, to an environment where untrusted clients can directly access the Xlog TCP port. 2 | 3 | If you want to expose it over unsecure HTTP (for development purposes or in LAN), please use `--serve-insecure true` flag. 4 | 5 | # Listening on specific network interface 6 | 7 | Xlog accepts `--bind` flag that defines the interface which xlog should listen to. `--bind` is in the format `:`. 8 | 9 | - To listen on all interfaces on port 3000 pass `--bind 0.0.0.0:3000` 10 | - To listen on specific interface pass the interface IP address `--bind 192.168.8.200:3000` 11 | 12 | # Readonly mode 13 | 14 | Xlog accept a `--readonly` flag to signal all features not to write to the disk. Readonly mode is not a safe measure for exposing the server to the internet. additionally make sure you sandbox the process in a restricted environment such as docker, CGROUPS or another user that has readonly access to the disk. 15 | 16 | Extensions can ignore the readonly flag so make sure you use trusted extensions only in case you intend to expose xlog to the internet. 17 | 18 | 19 | # Reporting Security Issues 20 | 21 | Please report any issues to me on Keybase: https://keybase.io/emadelsaid 22 | -------------------------------------------------------------------------------- /docs/Upgrading.md: -------------------------------------------------------------------------------- 1 | # Upgrading to V2 2 | 3 | If you were a user for v1 and would like to upgrade to v2 please take the following steps: 4 | 5 | * `--sidebar` command-line argument is removed. it had no effect for a long time already and was kept for backward compatibility 6 | * The `book` extension is renamed to `blocks` and has the same `book` shortcode. if you're importing it manually you need to change the import path to import `blocks` instead 7 | * For extensions development 8 | * You're now required to `xlog.RegisterExtension` your extension in your `init()` function then `Register*` the rest of your components in the extension `.Init()` function instead of the global one. this allow for future development to enable/disable extensions by the user. 9 | * Your extension can now check for `xlog.Config.Readonly` instead of `xlog.READONLY` during initialization (extension `Init()`) 10 | * `Get/Post/Delete/..etc` doesn't accept middlewares parameters anymore 11 | * The version extension has been removed as it was incomplete. if you are running xlog on the same directory that has `.versions` subdirectories you can remove them by running `rm -rf *.versions` 12 | * `github.repo` and `github.branch` are removed in favor of `github.url` which is the full URL of the editing. so it should work with other git online editors 13 | * `custom_css` was removed as its functionality can be achieved by using `custom.head` 14 | * `custom_head/before_view/after_view` name changed to `custom.` replacing the `_` with `.` for consistency with other flags 15 | -------------------------------------------------------------------------------- /docs/digital gardening.md: -------------------------------------------------------------------------------- 1 | A way for taking notes or writing down knowledge. it depends on rough and incomplete posts/notes instead of complete full blog posts. 2 | 3 | You end up with personal wiki. non-hierarchical and not complete. but it represent the scattered and linked knowledge of a person. 4 | 5 | This is Xlog digital garden. a set of markdown files that contains the knowledge related to xlog. and built with it into static website. everytime a page name is mentioned in text xlog converts it to a link. so you get to navigate the text and expand on abbreviations or concepts as you go. 6 | 7 | 8 | # Resources 9 | https://maggieappleton.com/garden-history/ 10 | 11 | -------------------------------------------------------------------------------- /docs/extensions.md: -------------------------------------------------------------------------------- 1 | Xlog is built to be small core that offers small set of features. And focus on offering a developer friendly public API to allow extending it with more features. 2 | 3 | # Extension points 4 | 5 | - Add any HTTP route with your handler function 6 | - Add Goldmark extension for parsing or rendering 7 | - Add a Preprocessor function to process page content before converting to HTML 8 | - Listen to Page events such as Write or Delete. 9 | - Define a helper function for templates 10 | - Add a directory to be parsed as a template 11 | - Add widgets in selected areas of the view page such as before or after rendered HTML 12 | - Add a command to the list of commands triggered with `Ctrl+K` which can execute arbitrary Javascript. 13 | - Add a route to be exported in case of building static site 14 | - Add arbitrary link to pages or any URL 15 | - Add quick command to appear on top of the view page 16 | 17 | # Overview 18 | 19 | An extension is a 20 | 21 | * Go module/package that imports xlog package 22 | * Can be hosted anywhere 23 | * Implements `xlog.Extension` interface 24 | * Has an `Init` function to register all of its components using `Register*` 25 | * Uses `RegisterExtension` functions in the `init` function of the package to register the extension 26 | * Adds or improves a feature in xlog using one or more of the extension points. 27 | * Imported by a the `main` package of your knowledgebase along with all other extensions and Xlog itself. an example can be found in Xlog CLI 28 | 29 | # Creating extensions 30 | 31 | * Hello world extension 32 | -------------------------------------------------------------------------------- /docs/goldmark.md: -------------------------------------------------------------------------------- 1 | A markdown parser written in Go. Used in Xlog for its extensibility. extensions can easily add feature to its parser and HTML renderer. 2 | 3 | https://github.com/yuin/goldmark -------------------------------------------------------------------------------- /docs/helper.md: -------------------------------------------------------------------------------- 1 | A helper function is a function that is used in an html template. the output of the function gets printed to the template. a helper function can take one or more parameters. 2 | 3 | Helper functions are Go `html/template` concept it's not introduced by xlog. an example can be found in html/template [documentation](https://pkg.go.dev/html/template#example-Template-Helpers). 4 | 5 | Extensions can define their own helpers to be used by any template using [`RegisterHelper`](https://pkg.go.dev/github.com/emad-elsaid/xlog#RegisterHelper) function. Registering a new helper has to be in the extension `init` function to be find at the time of parsing the templates. -------------------------------------------------------------------------------- /docs/public/custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/docs/public/custom.png -------------------------------------------------------------------------------- /docs/public/d32ac848ea161f9b384ed2ed81d657e3f150bcd3aa355a75741b95c76b873898.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/docs/public/d32ac848ea161f9b384ed2ed81d657e3f150bcd3aa355a75741b95c76b873898.avif -------------------------------------------------------------------------------- /docs/public/puzzle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/docs/public/puzzle.png -------------------------------------------------------------------------------- /docs/public/shortcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/docs/public/shortcode.png -------------------------------------------------------------------------------- /docs/public/sprout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/docs/public/sprout.png -------------------------------------------------------------------------------- /docs/public/static.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/docs/public/static.png -------------------------------------------------------------------------------- /docs/public/website.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/docs/public/website.png -------------------------------------------------------------------------------- /each_test.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIsIgnoredPath(t *testing.T) { 10 | assert.True(t, IsIgnoredPath(".git/config")) 11 | assert.True(t, IsIgnoredPath(".versions/config")) 12 | assert.False(t, IsIgnoredPath("index.md")) 13 | assert.False(t, IsIgnoredPath("something/something")) 14 | } 15 | 16 | func TestIsNil(t *testing.T) { 17 | assert.True(t, isNil[Page](nil)) 18 | assert.True(t, isNil[*Page](nil)) 19 | } 20 | -------------------------------------------------------------------------------- /events.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import "log/slog" 4 | 5 | type ( 6 | // a type used to define events to be used when the page is manipulated for 7 | // example modified, renamed, deleted...etc. 8 | PageEvent int 9 | // a function that handles a page event. this should be implemented by an 10 | // extension and then registered. it will get executed when the event is 11 | // triggered 12 | PageEventHandler func(Page) error 13 | ) 14 | 15 | // List of page events. extensions can use these events to register a function 16 | // to be executed when this event is triggered. extensions that require to be 17 | // notified when the page is created or overwritten or deleted should register 18 | // an event handler for the interesting events. 19 | const ( 20 | PageChanged PageEvent = iota 21 | PageDeleted 22 | PageNotFound // user requested a page that's not found 23 | ) 24 | 25 | // a map to keep all page events and respective list of event handlers 26 | var pageEvents = map[PageEvent][]PageEventHandler{} 27 | 28 | // Register an event handler to be executed when PageEvent is triggered. 29 | // extensions can use this to register hooks under specific page events. 30 | // extensions that keeps a cached version of the pages list for example needs to 31 | // register handlers to update its cache 32 | func Listen(e PageEvent, h PageEventHandler) { 33 | if _, ok := pageEvents[e]; !ok { 34 | pageEvents[e] = []PageEventHandler{} 35 | } 36 | 37 | pageEvents[e] = append(pageEvents[e], h) 38 | } 39 | 40 | // Trigger event handlers for a specific page event. page methods use this 41 | // function to trigger all registered handlers when the page is edited or 42 | // deleted for example. 43 | func Trigger(e PageEvent, p Page) { 44 | if _, ok := pageEvents[e]; !ok { 45 | return 46 | } 47 | 48 | for _, h := range pageEvents[e] { 49 | if err := h(p); err != nil { 50 | slog.Error("Failed to execute handler for event", "event", e, "handler", h, "error", err) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /extensions.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "log/slog" 5 | "slices" 6 | "strings" 7 | ) 8 | 9 | type Extension interface { 10 | Name() string 11 | Init() 12 | } 13 | 14 | var extensions = []Extension{} 15 | 16 | func RegisterExtension(e Extension) { 17 | extensions = append(extensions, e) 18 | } 19 | 20 | func initExtensions() { 21 | if Config.DisabledExtensions == "all" { 22 | slog.Info("extensions", "disabled", "all") 23 | return 24 | } 25 | 26 | disabled := strings.Split(Config.DisabledExtensions, ",") 27 | disabledNames := []string{} // because the user can input wrong extension name 28 | enabledNames := []string{} 29 | for i := range extensions { 30 | if slices.Contains(disabled, extensions[i].Name()) { 31 | disabledNames = append(disabledNames, extensions[i].Name()) 32 | continue 33 | } 34 | 35 | extensions[i].Init() 36 | enabledNames = append(enabledNames, extensions[i].Name()) 37 | } 38 | 39 | slog.Info("extensions", "enabled", enabledNames, "disabled", disabled) 40 | } 41 | -------------------------------------------------------------------------------- /extensions/all/all.go: -------------------------------------------------------------------------------- 1 | package all 2 | 3 | import ( 4 | _ "github.com/emad-elsaid/xlog/extensions/activitypub" 5 | _ "github.com/emad-elsaid/xlog/extensions/autolink" 6 | _ "github.com/emad-elsaid/xlog/extensions/autolink_pages" 7 | _ "github.com/emad-elsaid/xlog/extensions/blocks" 8 | _ "github.com/emad-elsaid/xlog/extensions/custom_widget" 9 | _ "github.com/emad-elsaid/xlog/extensions/date" 10 | _ "github.com/emad-elsaid/xlog/extensions/disqus" 11 | _ "github.com/emad-elsaid/xlog/extensions/editor" 12 | _ "github.com/emad-elsaid/xlog/extensions/embed" 13 | _ "github.com/emad-elsaid/xlog/extensions/file_operations" 14 | _ "github.com/emad-elsaid/xlog/extensions/frontmatter" 15 | _ "github.com/emad-elsaid/xlog/extensions/github" 16 | _ "github.com/emad-elsaid/xlog/extensions/gpg" 17 | _ "github.com/emad-elsaid/xlog/extensions/hashtags" 18 | _ "github.com/emad-elsaid/xlog/extensions/heading" 19 | _ "github.com/emad-elsaid/xlog/extensions/hotreload" 20 | _ "github.com/emad-elsaid/xlog/extensions/html" 21 | _ "github.com/emad-elsaid/xlog/extensions/images" 22 | _ "github.com/emad-elsaid/xlog/extensions/link_preview" 23 | _ "github.com/emad-elsaid/xlog/extensions/manifest" 24 | _ "github.com/emad-elsaid/xlog/extensions/mathjax" 25 | _ "github.com/emad-elsaid/xlog/extensions/mermaid" 26 | _ "github.com/emad-elsaid/xlog/extensions/opengraph" 27 | _ "github.com/emad-elsaid/xlog/extensions/pandoc" 28 | _ "github.com/emad-elsaid/xlog/extensions/photos" 29 | _ "github.com/emad-elsaid/xlog/extensions/recent" 30 | _ "github.com/emad-elsaid/xlog/extensions/rss" 31 | _ "github.com/emad-elsaid/xlog/extensions/rtl" 32 | _ "github.com/emad-elsaid/xlog/extensions/search" 33 | _ "github.com/emad-elsaid/xlog/extensions/shortcode" 34 | _ "github.com/emad-elsaid/xlog/extensions/sitemap" 35 | _ "github.com/emad-elsaid/xlog/extensions/sql_table" 36 | _ "github.com/emad-elsaid/xlog/extensions/star" 37 | _ "github.com/emad-elsaid/xlog/extensions/toc" 38 | _ "github.com/emad-elsaid/xlog/extensions/todo" 39 | _ "github.com/emad-elsaid/xlog/extensions/upload_file" 40 | ) 41 | -------------------------------------------------------------------------------- /extensions/autolink/autolink.go: -------------------------------------------------------------------------------- 1 | package autolink 2 | 3 | import ( 4 | "bytes" 5 | 6 | . "github.com/emad-elsaid/xlog" 7 | "github.com/yuin/goldmark/ast" 8 | "github.com/yuin/goldmark/renderer" 9 | "github.com/yuin/goldmark/util" 10 | ) 11 | 12 | func init() { 13 | RegisterExtension(AutoLink{}) 14 | } 15 | 16 | type AutoLink struct{} 17 | 18 | func (AutoLink) Name() string { return "autolink" } 19 | func (AutoLink) Init() { 20 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 21 | util.Prioritized(&extension{}, -1), 22 | )) 23 | } 24 | 25 | type extension struct{} 26 | 27 | func (h *extension) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 28 | reg.Register(ast.KindAutoLink, render) 29 | } 30 | 31 | func render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 32 | n := node.(*ast.AutoLink) 33 | if !entering { 34 | return ast.WalkContinue, nil 35 | } 36 | _, _ = w.WriteString(`') 51 | } else { 52 | _, _ = w.WriteString(`">`) 53 | } 54 | _, _ = w.Write(util.EscapeHTML(label)) 55 | _, _ = w.WriteString(``) 56 | return ast.WalkContinue, nil 57 | } 58 | -------------------------------------------------------------------------------- /extensions/autolink_pages/Autolink pages.md: -------------------------------------------------------------------------------- 1 | #extension 2 | 3 | Autolink pages extension converts any text that matches another page title to a link automatically. 4 | For example just by writing (xlog), it links to another page called `xlog.md`. 5 | So as a writer you don't have to go back retroactively and add links to your page in older pages. 6 | Also it keeps backlinks to the current page and show a list of them under the page for quick access to relevant pages. 7 | -------------------------------------------------------------------------------- /extensions/autolink_pages/autolink_pages.go: -------------------------------------------------------------------------------- 1 | package autolink_pages 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "html/template" 7 | "path" 8 | "sort" 9 | "strings" 10 | "sync" 11 | 12 | _ "embed" 13 | 14 | . "github.com/emad-elsaid/xlog" 15 | "github.com/yuin/goldmark/ast" 16 | east "github.com/yuin/goldmark/extension/ast" 17 | ) 18 | 19 | //go:embed templates 20 | var templates embed.FS 21 | 22 | type NormalizedPage struct { 23 | page Page 24 | normalizedName string 25 | } 26 | 27 | type fileInfoByNameLength []*NormalizedPage 28 | 29 | func (a fileInfoByNameLength) Len() int { return len(a) } 30 | func (a fileInfoByNameLength) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 31 | func (a fileInfoByNameLength) Less(i, j int) bool { 32 | return len(a[i].normalizedName) > len(a[j].normalizedName) 33 | } 34 | 35 | var autolinkPages []*NormalizedPage 36 | var autolinkPage_lck sync.Mutex 37 | 38 | func UpdatePagesList(Page) (err error) { 39 | autolinkPage_lck.Lock() 40 | defer autolinkPage_lck.Unlock() 41 | 42 | ps := MapPage(context.Background(), func(p Page) *NormalizedPage { 43 | return &NormalizedPage{ 44 | page: p, 45 | normalizedName: path.Base(strings.ToLower(p.Name())), 46 | } 47 | }) 48 | sort.Sort(fileInfoByNameLength(ps)) 49 | autolinkPages = ps 50 | return 51 | } 52 | 53 | func countTodos(p Page) (total int, done int) { 54 | _, tree := p.AST() 55 | tasks := FindAllInAST[*east.TaskCheckBox](tree) 56 | for _, v := range tasks { 57 | total++ 58 | if v.IsChecked { 59 | done++ 60 | } 61 | } 62 | 63 | return 64 | } 65 | 66 | func backlinksSection(p Page) template.HTML { 67 | if p.Name() == Config.Index { 68 | return "" 69 | } 70 | 71 | pages := MapPage(context.Background(), func(a Page) Page { 72 | _, tree := a.AST() 73 | if a.Name() == p.Name() || !containLinkTo(tree, p) { 74 | return nil 75 | } 76 | 77 | return a 78 | }) 79 | 80 | return Partial("backlinks", Locals{"pages": pages}) 81 | } 82 | 83 | func containLinkTo(n ast.Node, p Page) bool { 84 | if n.Kind() == KindPageLink { 85 | t, _ := n.(*PageLink) 86 | if t.page.FileName() == p.FileName() { 87 | return true 88 | } 89 | } 90 | if n.Kind() == ast.KindLink { 91 | t, _ := n.(*ast.Link) 92 | dst := string(t.Destination) 93 | 94 | // link is absolute: remove / 95 | if strings.HasPrefix(dst, "/") { 96 | path := strings.TrimPrefix(dst, "/") 97 | if string(path) == p.Name() { 98 | return true 99 | } 100 | } else { // link is relative: get relative part 101 | // TODO: what if another folder has the same filename? 102 | // * just ignore that fact 103 | // * dont support relative paths 104 | // there is no way to know who is the parent folder 105 | base := path.Base(p.Name()) 106 | if dst == base { 107 | return true 108 | } 109 | } 110 | } 111 | 112 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { 113 | if containLinkTo(c, p) { 114 | return true 115 | } 116 | 117 | if c == n.LastChild() { 118 | break 119 | } 120 | } 121 | 122 | return false 123 | } 124 | -------------------------------------------------------------------------------- /extensions/autolink_pages/extension.go: -------------------------------------------------------------------------------- 1 | package autolink_pages 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | func init() { 11 | RegisterExtension(AutoLinkPages{}) 12 | } 13 | 14 | type AutoLinkPages struct{} 15 | 16 | func (AutoLinkPages) Name() string { return "autolink-pages" } 17 | func (AutoLinkPages) Init() { 18 | if !Config.Readonly { 19 | Listen(PageChanged, UpdatePagesList) 20 | Listen(PageDeleted, UpdatePagesList) 21 | } 22 | 23 | RegisterWidget(WidgetAfterView, 1, backlinksSection) 24 | RegisterTemplate(templates, "templates") 25 | MarkdownConverter().Parser().AddOptions(parser.WithInlineParsers( 26 | util.Prioritized(&pageLinkParser{}, 999), 27 | )) 28 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 29 | util.Prioritized(&pageLinkRenderer{}, -1), 30 | )) 31 | } 32 | -------------------------------------------------------------------------------- /extensions/autolink_pages/node.go: -------------------------------------------------------------------------------- 1 | package autolink_pages 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/ast" 6 | ) 7 | 8 | var KindPageLink = ast.NewNodeKind("PageLink") 9 | 10 | type PageLink struct { 11 | ast.BaseInline 12 | page Page 13 | } 14 | 15 | func (*PageLink) Kind() ast.NodeKind { 16 | return KindPageLink 17 | } 18 | 19 | func (p *PageLink) Dump(source []byte, level int) { 20 | m := map[string]string{ 21 | "value": p.page.Name(), 22 | } 23 | ast.DumpHelper(p, source, level, m, nil) 24 | } 25 | -------------------------------------------------------------------------------- /extensions/autolink_pages/parser.go: -------------------------------------------------------------------------------- 1 | package autolink_pages 2 | 3 | import ( 4 | "strings" 5 | 6 | . "github.com/emad-elsaid/xlog" 7 | "github.com/yuin/goldmark/ast" 8 | "github.com/yuin/goldmark/parser" 9 | "github.com/yuin/goldmark/text" 10 | "github.com/yuin/goldmark/util" 11 | ) 12 | 13 | type pageLinkParser struct{} 14 | 15 | func (*pageLinkParser) Trigger() []byte { 16 | // ' ' indicates any white spaces and a line head 17 | return []byte{' ', '*', '_', '~', '('} 18 | } 19 | 20 | func (s *pageLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 21 | if pc.IsInLinkLabel() { 22 | return nil 23 | } 24 | 25 | if autolinkPages == nil { 26 | UpdatePagesList(nil) 27 | } 28 | 29 | line, segment := block.PeekLine() 30 | if line == nil { 31 | return nil 32 | } 33 | 34 | consumes := 0 35 | start := segment.Start 36 | c := line[0] 37 | // advance if current position is not a line head. 38 | if c == ' ' || c == '*' || c == '_' || c == '~' || c == '(' { 39 | consumes++ 40 | start++ 41 | line = line[1:] 42 | } 43 | 44 | var found Page 45 | var m int 46 | normalizedLine := strings.ToLower(string(line)) 47 | 48 | for _, p := range autolinkPages { 49 | if len(line) < len(p.normalizedName) { 50 | continue 51 | } 52 | 53 | // Found a page 54 | if strings.HasPrefix(normalizedLine, p.normalizedName) { 55 | found = p.page 56 | m = len(p.normalizedName) 57 | break 58 | } 59 | } 60 | 61 | if found == nil || 62 | (len(line) > m && util.IsAlphaNumeric(line[m])) { // next character is word character 63 | block.Advance(consumes) 64 | return nil 65 | } 66 | 67 | if consumes != 0 { 68 | s := segment.WithStop(segment.Start + 1) 69 | ast.MergeOrAppendTextSegment(parent, s) 70 | } 71 | consumes += m 72 | block.Advance(consumes) 73 | 74 | n := ast.NewTextSegment(text.NewSegment(start, start+m)) 75 | link := &PageLink{ 76 | page: found, 77 | } 78 | link.AppendChild(link, n) 79 | return link 80 | } 81 | -------------------------------------------------------------------------------- /extensions/autolink_pages/renderer.go: -------------------------------------------------------------------------------- 1 | package autolink_pages 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/yuin/goldmark/ast" 7 | "github.com/yuin/goldmark/renderer" 8 | "github.com/yuin/goldmark/util" 9 | ) 10 | 11 | type pageLinkRenderer struct{} 12 | 13 | func (h *pageLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 14 | reg.Register(KindPageLink, render) 15 | } 16 | 17 | func render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 18 | if entering { 19 | n := node.(*PageLink) 20 | url := n.page.Name() 21 | 22 | fmt.Fprintf(w, 23 | ``, 24 | util.EscapeHTML(util.URLEscape([]byte([]byte(url)), false)), 25 | ) 26 | 27 | if total, done := countTodos(n.page); total > 0 { 28 | isDone := "" 29 | if total == done { 30 | isDone = "is-success" 31 | } 32 | fmt.Fprintf(w, `%d/%d `, isDone, done, total) 33 | } 34 | } else { 35 | w.WriteString(``) 36 | } 37 | 38 | return ast.WalkContinue, nil 39 | } 40 | -------------------------------------------------------------------------------- /extensions/autolink_pages/templates/backlinks.html: -------------------------------------------------------------------------------- 1 | {{ if .pages }} 2 |

Backlinks

3 | {{ template "pages" .pages }} 4 | {{ end }} 5 | -------------------------------------------------------------------------------- /extensions/blocks/blocks.go: -------------------------------------------------------------------------------- 1 | package blocks 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "io/fs" 7 | "strings" 8 | 9 | "github.com/emad-elsaid/xlog" 10 | "github.com/emad-elsaid/xlog/extensions/shortcode" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | //go:embed templates 15 | var templates embed.FS 16 | 17 | //go:embed public 18 | var public embed.FS 19 | 20 | func init() { 21 | xlog.RegisterExtension(Blocks{}) 22 | } 23 | 24 | type Blocks struct{} 25 | 26 | func (Blocks) Name() string { return "blocks" } 27 | func (Blocks) Init() { 28 | RegisterShortCodes() 29 | xlog.RegisterTemplate(templates, "templates") 30 | xlog.RegisterStaticDir(public) 31 | registerBuildFiles() 32 | xlog.RegisterWidget(xlog.WidgetHead, 0, style) 33 | } 34 | 35 | func RegisterShortCodes() { 36 | fs.WalkDir(templates, "templates", func(path string, d fs.DirEntry, err error) error { 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if d.IsDir() { 42 | return nil 43 | } 44 | 45 | name := strings.TrimPrefix(path, "templates/") 46 | name = strings.TrimSuffix(name, ".html") 47 | 48 | shortcode.RegisterShortCode(name, shortcode.ShortCode{Render: block(name)}) 49 | 50 | return nil 51 | }) 52 | } 53 | 54 | func registerBuildFiles() { 55 | fs.WalkDir(public, ".", func(path string, d fs.DirEntry, err error) error { 56 | if err != nil { 57 | return err 58 | } 59 | 60 | if d.IsDir() { 61 | return nil 62 | } 63 | 64 | xlog.RegisterBuildPage("/"+path, false) 65 | 66 | return nil 67 | }) 68 | } 69 | 70 | func style(xlog.Page) template.HTML { 71 | return `` 72 | } 73 | 74 | func block(tpl string) func(xlog.Markdown) template.HTML { 75 | return func(in xlog.Markdown) template.HTML { 76 | b := map[string]any{} 77 | 78 | if err := yaml.Unmarshal([]byte(in), &b); err != nil { 79 | return template.HTML(err.Error()) 80 | } 81 | 82 | output := xlog.Partial(tpl, xlog.Locals(b)) 83 | 84 | return template.HTML(output) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /extensions/blocks/public/blocks.css: -------------------------------------------------------------------------------- 1 | .book { 2 | width: 226px; 3 | height: 346px; 4 | display: inline-block; 5 | margin: 0 2px 20px; 6 | box-shadow: 2px 4px 16px rgba(0, 0, 0, 0.12); 7 | position: relative; 8 | vertical-align: top; 9 | transition: all ease-in-out 0.2s; 10 | border-radius: 0 6px 6px 0; 11 | overflow: hidden; 12 | text-align: center; 13 | 14 | .meta { 15 | position: absolute; 16 | bottom: 0; 17 | width: 100%; 18 | padding: 12px 16px; 19 | color: #ffffff; 20 | text-shadow: black 1px 1px; 21 | background: linear-gradient( 22 | 0deg, 23 | rgba(0, 0, 0, 0.8) 0%, 24 | rgba(0, 0, 0, 0.4) 80%, 25 | rgba(2, 0, 36, 0) 100% 26 | ); 27 | 28 | .title { 29 | display: block; 30 | font-size: 1em; 31 | color: #ffffff; 32 | margin: 0.3em; 33 | } 34 | 35 | .author { 36 | font-size: 0.9em; 37 | } 38 | } 39 | 40 | .effect { 41 | position: absolute; 42 | left: 0; 43 | top: 0; 44 | width: 100%; 45 | height: 100%; 46 | background: linear-gradient( 47 | to right, 48 | rgba(0, 0, 0, 0.02) 0%, 49 | rgba(0, 0, 0, 0.05) 0.75%, 50 | rgba(255, 255, 255, 0.5) 1%, 51 | rgba(255, 255, 255, 0.6) 1.3%, 52 | rgba(255, 255, 255, 0.5) 1.4%, 53 | rgba(255, 255, 255, 0.3) 1.5%, 54 | rgba(255, 255, 255, 0.3) 2.4%, 55 | rgba(0, 0, 0, 0.05) 2.7%, 56 | rgba(0, 0, 0, 0.05) 3.5%, 57 | rgba(255, 255, 255, 0.3) 4%, 58 | rgba(255, 255, 255, 0.3) 4.5%, 59 | rgba(244, 244, 244, 0.1) 5.4%, 60 | rgba(244, 244, 244, 0.1) 99%, 61 | rgba(144, 144, 144, 0.2) 100% 62 | ); 63 | box-shadow: inset 0 -1px 4px rgba(0, 0, 0, 0.12); 64 | } 65 | 66 | .cover { 67 | display: flex; 68 | justify-content: center; 69 | align-items: center; 70 | height: 100%; 71 | border-radius: 0 6px 6px 0; 72 | overflow: hidden; 73 | 74 | img { 75 | min-width: 100%; 76 | min-height: 100%; 77 | } 78 | } 79 | } 80 | 81 | @media screen and (max-width: 600px) { 82 | .book { 83 | width: 42vw; 84 | height: calc(1.53 * 42vw); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /extensions/blocks/templates/book.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ .title }} 4 |
5 | 6 | {{- if .title }} 7 |
8 |
{{ .title }}
9 | {{ if .author }}
{{ .author }}
{{ end }} 10 |
11 | {{- end }} 12 |
13 | -------------------------------------------------------------------------------- /extensions/blocks/templates/github-user.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | {{.name}} 6 |
7 |
8 |
9 |

{{.name}}

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /extensions/blocks/templates/hero.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{with .image }}{{end}} 4 |

{{.title}}

5 |

{{.subtitle}}

6 |
7 |
8 | -------------------------------------------------------------------------------- /extensions/blocks/templates/person.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | {{.name}} 6 |
7 |
8 |
9 |

{{.name}}

10 |

{{.byline}}

11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /extensions/custom_widget/main.go: -------------------------------------------------------------------------------- 1 | package custom_widget 2 | 3 | import ( 4 | "flag" 5 | "html/template" 6 | "os" 7 | 8 | "github.com/emad-elsaid/memoize" 9 | . "github.com/emad-elsaid/xlog" 10 | ) 11 | 12 | var head_file, before_view_file, after_view_file string 13 | 14 | func init() { 15 | flag.StringVar(&head_file, "custom.head", "", "path to a file it's content will be included in every page tag") 16 | flag.StringVar(&before_view_file, "custom.before_view", "", "path to a file it's content will be included in every page BEFORE the content of the page") 17 | flag.StringVar(&after_view_file, "custom.after_view", "", "path to a file it's content will be included in every page AFTER the content of the page") 18 | 19 | RegisterExtension(CustomWidget{}) 20 | } 21 | 22 | type CustomWidget struct{} 23 | 24 | func (CustomWidget) Name() string { return "custom-widget" } 25 | func (CustomWidget) Init() { 26 | if head_file != "" { 27 | RegisterWidget(WidgetHead, 1, func(Page) template.HTML { 28 | return readFile(head_file) 29 | }) 30 | } 31 | if before_view_file != "" { 32 | RegisterWidget(WidgetBeforeView, 1, func(Page) template.HTML { 33 | return readFile(before_view_file) 34 | }) 35 | } 36 | if after_view_file != "" { 37 | RegisterWidget(WidgetAfterView, 1, func(Page) template.HTML { 38 | return readFile(after_view_file) 39 | }) 40 | } 41 | } 42 | 43 | var readFile = memoize.New(func(f string) template.HTML { 44 | b, err := os.ReadFile(f) 45 | if err != nil { 46 | panic(err) 47 | } 48 | 49 | return template.HTML(b) 50 | }) 51 | -------------------------------------------------------------------------------- /extensions/date/Date and Calendar.md: -------------------------------------------------------------------------------- 1 | #extension 2 | 3 | Date extension recognizes any date mentions in the page. It converts the date to a link to a day page that lists all pages that includes the same date. 4 | For example `2025-02-14` is displayed at follows 2025-02-14 5 | 6 | The extension can recognize several formats: 7 | 8 | ``` 9 | 2006-1-2 10 | 2006-January-2 11 | 2006/January/2 12 | 2006\January\2 13 | 2006-Jan-2 14 | 2006/Jan/2 15 | 2006\Jan\2 16 | 2-January-2006 17 | 2/January/2006 18 | 2\January\2006 19 | 2-Jan-2006 20 | 2/Jan/2006 21 | 2\Jan\2006 22 | Jan-2-2006 23 | Jan/2/2006 24 | Jan\2\2006 25 | January-2-2006 26 | January/2/2006 27 | January\2\2006 28 | ``` 29 | 30 | It also Adds a link to Calendar which shows all the months where dates has been mentioned along with pages in each day. 31 | 32 | So dates can be used to write a diary. or mention a deadline in the future for a task. 33 | -------------------------------------------------------------------------------- /extensions/date/extension.go: -------------------------------------------------------------------------------- 1 | package date 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | func init() { 11 | RegisterExtension(Date{}) 12 | } 13 | 14 | type Date struct{} 15 | 16 | func (Date) Name() string { return "date" } 17 | func (Date) Init() { 18 | RegisterTemplate(templates, "templates") 19 | RegisterLink(links) 20 | RegisterBuildPage(`/+/calendar`, true) 21 | 22 | Get(`/+/date/{date}`, dateHandler) 23 | Get(`/+/calendar`, calendarHandler) 24 | 25 | MarkdownConverter().Parser().AddOptions(parser.WithInlineParsers( 26 | util.Prioritized(&dateParser{}, 999), 27 | )) 28 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 29 | util.Prioritized(&dateRenderer{}, 0), 30 | )) 31 | } 32 | -------------------------------------------------------------------------------- /extensions/date/links.go: -------------------------------------------------------------------------------- 1 | package date 2 | 3 | import ( 4 | "html/template" 5 | 6 | "github.com/emad-elsaid/xlog" 7 | ) 8 | 9 | func links(xlog.Page) []xlog.Command { 10 | return []xlog.Command{ 11 | Calendar{}, 12 | } 13 | } 14 | 15 | type Calendar struct{} 16 | 17 | func (Calendar) Icon() string { return "fa-regular fa-calendar-days" } 18 | func (Calendar) Name() string { return "Calendar" } 19 | func (Calendar) Attrs() map[template.HTMLAttr]any { 20 | return map[template.HTMLAttr]any{ 21 | "href": "/+/calendar", 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /extensions/date/node.go: -------------------------------------------------------------------------------- 1 | package date 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/yuin/goldmark/ast" 8 | ) 9 | 10 | var KindDate = ast.NewNodeKind("Date") 11 | 12 | type DateNode struct { 13 | ast.BaseInline 14 | time time.Time 15 | } 16 | 17 | func (d *DateNode) Dump(source []byte, level int) { 18 | m := map[string]string{ 19 | "value": fmt.Sprintf("%#v", d), 20 | } 21 | ast.DumpHelper(d, source, level, m, nil) 22 | } 23 | 24 | func (d *DateNode) Kind() ast.NodeKind { 25 | return KindDate 26 | } 27 | -------------------------------------------------------------------------------- /extensions/date/parser.go: -------------------------------------------------------------------------------- 1 | package date 2 | 3 | import ( 4 | "time" 5 | "unicode" 6 | 7 | "github.com/yuin/goldmark/ast" 8 | "github.com/yuin/goldmark/parser" 9 | "github.com/yuin/goldmark/text" 10 | ) 11 | 12 | type dateParser struct{} 13 | 14 | func (s *dateParser) Trigger() []byte { 15 | return []byte{' '} 16 | } 17 | 18 | var ( 19 | datePatterns = []string{ 20 | `2006-1-2`, 21 | 22 | `2006-January-2`, 23 | `2006/January/2`, 24 | `2006\January\2`, 25 | 26 | `2006-Jan-2`, 27 | `2006/Jan/2`, 28 | `2006\Jan\2`, 29 | 30 | `2-January-2006`, 31 | `2/January/2006`, 32 | `2\January\2006`, 33 | 34 | `2-Jan-2006`, 35 | `2/Jan/2006`, 36 | `2\Jan\2006`, 37 | 38 | `Jan-2-2006`, 39 | `Jan/2/2006`, 40 | `Jan\2\2006`, 41 | 42 | `January-2-2006`, 43 | `January/2/2006`, 44 | `January\2\2006`, 45 | } 46 | ) 47 | 48 | func (s *dateParser) Parse(parent ast.Node, reader text.Reader, pc parser.Context) ast.Node { 49 | l, _ := reader.PeekLine() 50 | if len(l) < 2 { 51 | return nil 52 | } 53 | 54 | advance := 0 55 | 56 | if l[0] == ' ' { 57 | advance++ 58 | l = l[1:] 59 | } 60 | 61 | space := len(l) 62 | separators := 0 63 | for i, b := range l { 64 | if !unicode.In(rune(b), unicode.Digit, unicode.Letter, unicode.Dash) && 65 | b != '/' && 66 | b != '\\' { 67 | space = i 68 | break 69 | } 70 | 71 | // keep track of how many separators 72 | if unicode.In(rune(b), unicode.Dash) || b == '/' || b == '\\' { 73 | separators++ 74 | } 75 | 76 | if separators > 2 { 77 | space = i 78 | break 79 | } 80 | } 81 | 82 | advance += space 83 | l = l[:space] 84 | 85 | for _, pattern := range datePatterns { 86 | t, err := time.Parse(pattern, string(l)) 87 | if err == nil { 88 | reader.Advance(advance) 89 | return &DateNode{ 90 | time: t, 91 | } 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /extensions/date/renderer.go: -------------------------------------------------------------------------------- 1 | package date 2 | 3 | import ( 4 | "fmt" 5 | 6 | . "github.com/emad-elsaid/xlog" 7 | "github.com/yuin/goldmark/ast" 8 | "github.com/yuin/goldmark/renderer" 9 | "github.com/yuin/goldmark/util" 10 | ) 11 | 12 | type dateRenderer struct{} 13 | 14 | func (s *dateRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 15 | reg.Register(KindDate, s.render) 16 | } 17 | 18 | func (s *dateRenderer) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 19 | if !entering { 20 | return ast.WalkContinue, nil 21 | } 22 | 23 | node, ok := n.(*DateNode) 24 | if !ok { 25 | return ast.WalkContinue, nil 26 | } 27 | 28 | path := fmt.Sprintf(`/+/date/%s`, node.time.Format("2-1-2006")) 29 | RegisterBuildPage(path, true) 30 | 31 | fmt.Fprintf(w, ` %s `, path, node.time.Format("2 January 2006")) 32 | 33 | return ast.WalkContinue, nil 34 | } 35 | -------------------------------------------------------------------------------- /extensions/date/templates/calendar.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 | 24 | 25 | {{range .calendar}} 26 |

{{.Year}}

27 | {{range .Months}} 28 |

{{.Name}}

29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {{range .Days}} 43 | 44 | {{range .}} 45 | 76 | {{end}} 77 | 78 | {{end}} 79 | 80 |
SunMonTueWedThuFriSat
46 | {{if .}} 47 | 48 | {{ if .Pages }} 49 | 50 | {{.Date.Day}} 51 | 52 | {{else}} 53 | {{.Date.Day}} 54 | {{end}} 55 | 56 | {{ if .Pages }} 57 | 72 | {{end}} 73 | 74 | {{end}} 75 |
81 | {{end}} 82 | {{else}} 83 |
84 | There are no posts that contains dates yet... 85 |
86 | {{end}} 87 | 88 | {{ template "footer" . }} 89 | -------------------------------------------------------------------------------- /extensions/date/templates/date.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 | {{ template "pages" .pages }} 3 | {{ template "footer" . }} 4 | -------------------------------------------------------------------------------- /extensions/disqus/main.go: -------------------------------------------------------------------------------- 1 | package disqus 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "html/template" 7 | 8 | . "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | const tmpl = ` 12 |
13 | ` 29 | 30 | var domain string 31 | 32 | func init() { 33 | flag.StringVar(&domain, "disqus", "", "Disqus domain name for example: xlog-emadelsaid.disqus.com") 34 | RegisterExtension(Disqus{}) 35 | } 36 | 37 | type Disqus struct{} 38 | 39 | func (Disqus) Name() string { return "disqus" } 40 | func (Disqus) Init() { 41 | RegisterWidget(WidgetAfterView, 2, widget) 42 | } 43 | 44 | func widget(p Page) template.HTML { 45 | if domain == "" { 46 | return "" 47 | } 48 | 49 | script := fmt.Sprintf(tmpl, template.JSEscapeString(p.Name()), domain) 50 | return template.HTML(script) 51 | } 52 | -------------------------------------------------------------------------------- /extensions/editor/extension.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "html/template" 7 | "log/slog" 8 | "net/url" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/emad-elsaid/xlog" 15 | ) 16 | 17 | var editor string 18 | 19 | func init() { 20 | flag.StringVar(&editor, "editor", os.Getenv("EDITOR"), "command to use to open pages for editing") 21 | 22 | xlog.RequireHTMX() 23 | xlog.RegisterExtension(Editor{}) 24 | } 25 | 26 | type Editor struct{} 27 | 28 | func (Editor) Name() string { return "editor" } 29 | func (Editor) Init() { 30 | if xlog.Config.Readonly { 31 | return 32 | } 33 | 34 | xlog.RegisterQuickCommand(links) 35 | xlog.Post(`/+/editor/{page...}`, editorHandler) 36 | xlog.Listen(xlog.PageNotFound, newPage) 37 | } 38 | 39 | func newPage(p xlog.Page) error { 40 | openEditor(p) 41 | 42 | return nil 43 | } 44 | 45 | func openEditor(page xlog.Page) { 46 | if page == nil { 47 | return 48 | } 49 | 50 | // if it's like a .ico, .jpeg, .so...etc ignore it, it's not a page we 51 | // should create, maybe just a static file that's missing 52 | if ext := len(filepath.Ext(page.Name())); ext > 0 && ext <= 4 { 53 | return 54 | } 55 | 56 | segments := strings.Split(editor, " ") 57 | if len(segments) == 0 { 58 | return 59 | } 60 | 61 | name := segments[0] 62 | args := append(segments[1:], page.FileName()) 63 | cmd := exec.Command(name, args...) 64 | 65 | if err := cmd.Start(); err != nil { 66 | slog.Error("Error start command", "command", cmd.String(), "error", err) 67 | } 68 | } 69 | 70 | func editorHandler(r xlog.Request) xlog.Output { 71 | page := xlog.NewPage(r.PathValue("page")) 72 | slog.Info("Editing page", "name", page) 73 | 74 | openEditor(page) 75 | 76 | return xlog.NoContent() 77 | } 78 | 79 | func links(p xlog.Page) []xlog.Command { 80 | if len(p.FileName()) == 0 { 81 | return nil 82 | } 83 | 84 | return []xlog.Command{editButton{page: p}} 85 | } 86 | 87 | type editButton struct { 88 | page xlog.Page 89 | } 90 | 91 | func (editButton) Icon() string { return "fa-solid fa-pen" } 92 | func (editButton) Name() string { return "Edit" } 93 | func (e editButton) Attrs() map[template.HTMLAttr]any { 94 | return map[template.HTMLAttr]any{ 95 | "hx-post": fmt.Sprintf("/+/editor/%s", url.PathEscape(e.page.Name())), 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /extensions/embed/embed.go: -------------------------------------------------------------------------------- 1 | package embed 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "strings" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | "github.com/emad-elsaid/xlog/extensions/shortcode" 10 | ) 11 | 12 | func init() { 13 | xlog.RegisterExtension(Embed{}) 14 | } 15 | 16 | type Embed struct{} 17 | 18 | func (Embed) Name() string { return "embed" } 19 | func (Embed) Init() { 20 | shortcode.RegisterShortCode("embed", shortcode.ShortCode{Render: embedShortcode}) 21 | } 22 | 23 | func embedShortcode(in xlog.Markdown) template.HTML { 24 | p := xlog.NewPage(strings.TrimSpace(string(in))) 25 | if p == nil || !p.Exists() { 26 | return template.HTML(fmt.Sprintf("Page: %s doesn't exist", in)) 27 | } 28 | 29 | return p.Render() 30 | } 31 | -------------------------------------------------------------------------------- /extensions/file_operations/delete.go: -------------------------------------------------------------------------------- 1 | package file_operations 2 | 3 | import ( 4 | "html/template" 5 | "log/slog" 6 | "net/url" 7 | 8 | . "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | type PageDelete struct { 12 | page Page 13 | } 14 | 15 | func (PageDelete) Icon() string { return "fa-solid fa-trash" } 16 | func (PageDelete) Name() string { return "Delete" } 17 | func (f PageDelete) Attrs() map[template.HTMLAttr]any { 18 | return map[template.HTMLAttr]any{ 19 | "href": "/+/file/delete?page=" + url.QueryEscape(f.page.Name()), 20 | "hx-delete": "/+/file/delete?page=" + url.QueryEscape(f.page.Name()), 21 | "hx-confirm": "Are you sure?", 22 | } 23 | } 24 | 25 | func (f PageDelete) Handler(r Request) Output { 26 | name := r.FormValue("page") 27 | page := NewPage(name) 28 | if page == nil || !page.Exists() { 29 | slog.Error("Can't delete page", "page", page, "name", name) 30 | } else { 31 | page.Delete() 32 | } 33 | 34 | return func(w Response, r Request) { 35 | w.Header().Add("HX-Redirect", "/") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /extensions/file_operations/file_operations.go: -------------------------------------------------------------------------------- 1 | package file_operations 2 | 3 | import ( 4 | "embed" 5 | 6 | _ "embed" 7 | 8 | . "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | //go:embed templates 12 | var templates embed.FS 13 | 14 | func init() { 15 | RegisterExtension(FileOps{}) 16 | } 17 | 18 | type FileOps struct{} 19 | 20 | func (FileOps) Name() string { return "file-operations" } 21 | func (FileOps) Init() { 22 | if Config.Readonly { 23 | return 24 | } 25 | 26 | RequireHTMX() 27 | RegisterCommand(commands) 28 | RegisterQuickCommand(commands) 29 | RegisterTemplate(templates, "templates") 30 | Post(`/+/file/rename`, PageRename{}.Handler) 31 | Get(`/+/file/rename`, PageRename{}.Form) 32 | Delete(`/+/file/delete`, PageDelete{}.Handler) 33 | } 34 | 35 | func commands(p Page) []Command { 36 | if len(p.FileName()) == 0 { 37 | return nil 38 | } 39 | 40 | return []Command{PageDelete{p}, PageRename{p}} 41 | } 42 | -------------------------------------------------------------------------------- /extensions/file_operations/rename.go: -------------------------------------------------------------------------------- 1 | package file_operations 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/url" 7 | "os" 8 | "path" 9 | 10 | . "github.com/emad-elsaid/xlog" 11 | ) 12 | 13 | type PageRename struct { 14 | page Page 15 | } 16 | 17 | func (PageRename) Icon() string { return "fa-solid fa-i-cursor" } 18 | func (PageRename) Name() string { return "Rename" } 19 | func (f PageRename) Attrs() map[template.HTMLAttr]any { 20 | return map[template.HTMLAttr]any{ 21 | "href": "/+/file/rename?page=" + url.QueryEscape(f.page.Name()), 22 | "hx-get": "/+/file/rename?page=" + url.QueryEscape(f.page.Name()), 23 | "hx-target": "body", 24 | "hx-swap": "beforeend", 25 | } 26 | } 27 | 28 | func (f PageRename) Form(r Request) Output { 29 | name := r.FormValue("page") 30 | page := NewPage(name) 31 | 32 | return Render("rename-form", map[string]any{ 33 | "page": page, 34 | }) 35 | } 36 | 37 | func (f PageRename) Handler(r Request) Output { 38 | old := NewPage(r.FormValue("old")) 39 | if old == nil || !old.Exists() { 40 | return BadRequest("file doesn't exist") 41 | } 42 | 43 | ext := path.Ext(old.FileName()) 44 | basename := r.FormValue("new") 45 | newName := basename + ext 46 | 47 | os.Rename(old.FileName(), newName) 48 | old.Write(Markdown(fmt.Sprintf("Renamed to: %s", basename))) 49 | 50 | return func(w Response, r Request) { 51 | w.Header().Add("HX-Redirect", "/"+basename) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /extensions/file_operations/templates/rename-form.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /extensions/frontmatter/extension.go: -------------------------------------------------------------------------------- 1 | package frontmatter 2 | 3 | import ( 4 | "github.com/emad-elsaid/xlog" 5 | meta "github.com/yuin/goldmark-meta" 6 | ) 7 | 8 | func init() { 9 | xlog.RegisterExtension(Frontmatter{}) 10 | } 11 | 12 | type Frontmatter struct{} 13 | 14 | func (Frontmatter) Name() string { return "frontmatter" } 15 | func (Frontmatter) Init() { 16 | m := meta.New( 17 | meta.WithStoresInDocument(), 18 | ) 19 | 20 | m.Extend(xlog.MarkdownConverter()) 21 | xlog.RegisterProperty(MetaProperties) 22 | } 23 | 24 | type MetaProperty struct { 25 | NameVal string 26 | Val any 27 | } 28 | 29 | func (m MetaProperty) Name() string { return m.NameVal } 30 | func (m MetaProperty) Icon() string { return "fa-solid fa-table-list" } 31 | func (m MetaProperty) Value() any { return m.Val } 32 | 33 | func MetaProperties(p xlog.Page) []xlog.Property { 34 | _, ast := p.AST() 35 | if ast == nil { 36 | return nil 37 | } 38 | 39 | metaData := ast.OwnerDocument().Meta() 40 | if len(metaData) == 0 { 41 | return nil 42 | } 43 | 44 | ps := make([]xlog.Property, 0, len(metaData)) 45 | for k, v := range metaData { 46 | ps = append(ps, MetaProperty{ 47 | NameVal: k, 48 | Val: v, 49 | }) 50 | } 51 | 52 | return ps 53 | } 54 | -------------------------------------------------------------------------------- /extensions/github/api.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "os" 9 | "strings" 10 | 11 | "github.com/emad-elsaid/xlog" 12 | "github.com/emad-elsaid/xlog/extensions/shortcode" 13 | "github.com/google/go-github/v53/github" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | var githubTokenPossibleVariables = []string{"GITHUB_TOKEN", "GITHUB_API_TOKEN"} 18 | var tokenNotAvailable = errors.New("Github token env variable not found in any of: " + strings.Join(githubTokenPossibleVariables, ", ")) 19 | var perPage = 100 20 | 21 | func init() { 22 | shortcode.RegisterShortCode("github-search-issues", shortcode.ShortCode{Render: seachIssuesShortcode}) 23 | } 24 | 25 | func seachIssuesShortcode(in xlog.Markdown) template.HTML { 26 | return template.HTML(issues(context.Background(), string(in))) 27 | } 28 | 29 | func token() (string, error) { 30 | for _, v := range githubTokenPossibleVariables { 31 | value := os.Getenv(v) 32 | if len(value) > 0 { 33 | return value, nil 34 | } 35 | } 36 | 37 | return "", tokenNotAvailable 38 | } 39 | 40 | func client() (*github.Client, error) { 41 | token, err := token() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | ctx := context.Background() 47 | ts := oauth2.StaticTokenSource( 48 | &oauth2.Token{AccessToken: token}, 49 | ) 50 | tc := oauth2.NewClient(ctx, ts) 51 | 52 | return github.NewClient(tc), nil 53 | } 54 | 55 | func issues(ctx context.Context, query string) string { 56 | client, err := client() 57 | if err != nil { 58 | return err.Error() 59 | } 60 | 61 | result, _, err := client.Search.Issues(ctx, query, &github.SearchOptions{ 62 | ListOptions: github.ListOptions{ 63 | PerPage: perPage, 64 | }, 65 | }) 66 | if err != nil { 67 | return err.Error() 68 | } 69 | 70 | if len(result.Issues) == 0 { 71 | return fmt.Sprintf("No results for query: %s", query) 72 | } 73 | 74 | issues := "
    " 75 | for _, i := range result.Issues { 76 | assignee := i.GetUser() 77 | 78 | issues += fmt.Sprintf(`
  • 79 | 80 |
    81 | 82 |
    83 | %s 84 |
    85 |
  • `, assignee.GetAvatarURL(), i.GetHTMLURL(), i.GetTitle()) 86 | } 87 | issues += "
" 88 | 89 | return issues 90 | } 91 | -------------------------------------------------------------------------------- /extensions/github/main.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "html/template" 7 | 8 | . "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | var editUrl string 12 | 13 | func init() { 14 | flag.StringVar(&editUrl, "github.url", "", "Repository url for 'edit on Github' quick action e.g https://github.com/emad-elsaid/xlog/edit/master/docs") 15 | RegisterExtension(Github{}) 16 | } 17 | 18 | type Github struct{} 19 | 20 | func (Github) Name() string { return "github" } 21 | func (Github) Init() { 22 | if len(editUrl) == 0 { 23 | return 24 | } 25 | 26 | RegisterQuickCommand(quickCommands) 27 | } 28 | 29 | func quickCommands(p Page) []Command { 30 | if len(p.FileName()) == 0 { 31 | return nil 32 | } 33 | 34 | return []Command{editOnGithub{page: p}} 35 | } 36 | 37 | type editOnGithub struct { 38 | page Page 39 | } 40 | 41 | func (e editOnGithub) Icon() string { return "fa-brands fa-github" } 42 | func (e editOnGithub) Name() string { return "Edit on Github" } 43 | func (e editOnGithub) Attrs() map[template.HTMLAttr]any { 44 | return map[template.HTMLAttr]any{ 45 | "href": fmt.Sprintf("%s/%s", editUrl, e.page.FileName()), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /extensions/gpg/commands.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "html/template" 5 | "net/url" 6 | "path" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | const decryptableExt = ".pgp" 12 | 13 | func commands(p xlog.Page) []xlog.Command { 14 | if !p.Exists() { 15 | return nil 16 | } 17 | 18 | if len(gpgId) == 0 { 19 | return nil 20 | } 21 | 22 | if path.Ext(p.FileName()) == decryptableExt { 23 | return []xlog.Command{ 24 | &decryptCommand{page: p}, 25 | } 26 | } else { 27 | return []xlog.Command{ 28 | &encryptCommand{page: p}, 29 | } 30 | } 31 | } 32 | 33 | type encryptCommand struct { 34 | page xlog.Page 35 | } 36 | 37 | func (e *encryptCommand) Icon() string { return "fa-solid fa-lock" } 38 | func (e *encryptCommand) Name() string { return "Make private" } 39 | func (e *encryptCommand) Attrs() map[template.HTMLAttr]any { 40 | return map[template.HTMLAttr]any{ 41 | "hx-post": "/+/gpg/encrypt/" + url.PathEscape(e.page.Name()), 42 | } 43 | } 44 | 45 | type decryptCommand struct { 46 | page xlog.Page 47 | } 48 | 49 | func (e *decryptCommand) Icon() string { return "fa-solid fa-lock-open has-text-danger" } 50 | func (e *decryptCommand) Name() string { return "Make public" } 51 | func (e *decryptCommand) Attrs() map[template.HTMLAttr]any { 52 | return map[template.HTMLAttr]any{ 53 | "hx-post": "/+/gpg/decrypt/" + url.PathEscape(e.page.Name()), 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /extensions/gpg/gpg.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/emad-elsaid/xlog" 7 | ) 8 | 9 | const EXT = ".md.pgp" 10 | 11 | var gpgId string 12 | 13 | func init() { 14 | flag.StringVar(&gpgId, "gpg", "", "PGP key ID to decrypt and edit .md.pgp files using gpg. if empty encryption will be off") 15 | xlog.RegisterExtension(PGP{}) 16 | } 17 | 18 | type PGP struct{} 19 | 20 | func (PGP) Name() string { return "pgp" } 21 | func (PGP) Init() { 22 | xlog.RegisterPageSource(new(encryptedPages)) 23 | 24 | if !xlog.Config.Readonly { 25 | xlog.RegisterCommand(commands) 26 | xlog.RequireHTMX() 27 | xlog.Post(`/+/gpg/encrypt/{page...}`, encryptHandler) 28 | xlog.Post(`/+/gpg/decrypt/{page...}`, decryptHandler) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /extensions/gpg/handlers.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/emad-elsaid/xlog" 8 | ) 9 | 10 | var ( 11 | deleteFailedErr = errors.New("Couldn't delete original page") 12 | encryptionFailedErr = errors.New("Couldn't encrypt page") 13 | ) 14 | 15 | func encryptHandler(r xlog.Request) xlog.Output { 16 | p := xlog.NewPage(r.PathValue("page")) 17 | if p == nil || !p.Exists() { 18 | return xlog.NotFound("page not found") 19 | } 20 | 21 | encryptedPage := page{name: p.Name()} 22 | if !encryptedPage.Write(p.Content()) { 23 | return xlog.InternalServerError(encryptionFailedErr) 24 | } 25 | 26 | if !p.Delete() { 27 | return xlog.InternalServerError(deleteFailedErr) 28 | } 29 | 30 | return func(w xlog.Response, r xlog.Request) { 31 | w.Header().Add("HX-Refresh", "true") 32 | w.WriteHeader(http.StatusNoContent) 33 | } 34 | } 35 | 36 | func decryptHandler(r xlog.Request) xlog.Output { 37 | p := xlog.NewPage(r.PathValue("page")) 38 | if p == nil || !p.Exists() { 39 | return xlog.NotFound("page not found") 40 | } 41 | 42 | content := p.Content() 43 | if !p.Delete() { 44 | return xlog.InternalServerError(deleteFailedErr) 45 | } 46 | 47 | decryptedPage := xlog.NewPage(p.Name()) 48 | if decryptedPage == nil || !decryptedPage.Write(content) { 49 | return xlog.InternalServerError(encryptionFailedErr) 50 | } 51 | 52 | return func(w xlog.Response, r xlog.Request) { 53 | w.Header().Add("HX-Refresh", "true") 54 | w.WriteHeader(http.StatusNoContent) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /extensions/gpg/page.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/emad-elsaid/xlog" 15 | "github.com/yuin/goldmark/ast" 16 | "github.com/yuin/goldmark/text" 17 | ) 18 | 19 | type page struct { 20 | name string 21 | ast ast.Node 22 | } 23 | 24 | func (p *page) Name() string { return p.name } 25 | func (p *page) FileName() string { return filepath.FromSlash(p.name) + EXT } 26 | 27 | func (p *page) Exists() bool { 28 | _, err := os.Stat(p.FileName()) 29 | return err == nil 30 | } 31 | 32 | func (p *page) Render() template.HTML { 33 | content := p.Content() 34 | content = xlog.PreProcess(content) 35 | var buf bytes.Buffer 36 | if err := xlog.MarkdownConverter().Convert([]byte(content), &buf); err != nil { 37 | return template.HTML(err.Error()) 38 | } 39 | 40 | return template.HTML(buf.String()) 41 | } 42 | 43 | func (p *page) Content() xlog.Markdown { 44 | cmd := exec.Command("gpg", "--decrypt", p.FileName()) 45 | out, err := cmd.Output() 46 | if err != nil { 47 | slog.Error("Coudln't decrypt", "file", p.FileName(), "error", err) 48 | } 49 | 50 | return xlog.Markdown(out) 51 | } 52 | 53 | func (p *page) ModTime() time.Time { 54 | s, err := os.Stat(p.FileName()) 55 | if err != nil { 56 | return time.Time{} 57 | } 58 | 59 | return s.ModTime() 60 | } 61 | 62 | func (p *page) Delete() bool { 63 | defer xlog.Trigger(xlog.PageDeleted, p) 64 | 65 | if p.Exists() { 66 | err := os.Remove(p.FileName()) 67 | if err != nil { 68 | fmt.Printf("Can't delete `%s`, err: %s\n", p.Name(), err) 69 | return false 70 | } 71 | } 72 | return true 73 | } 74 | 75 | func (p *page) Write(content xlog.Markdown) bool { 76 | defer xlog.Trigger(xlog.PageChanged, p) 77 | 78 | name := p.FileName() 79 | os.MkdirAll(filepath.Dir(name), 0700) 80 | 81 | content = xlog.Markdown(strings.ReplaceAll(string(content), "\r\n", "\n")) 82 | cmd := exec.Command("gpg", "-r", gpgId, "--output", p.FileName(), "--batch", "--yes", "--encrypt") 83 | cmd.Stdin = bytes.NewBuffer([]byte(content)) 84 | 85 | out, err := cmd.Output() 86 | if err != nil { 87 | fmt.Printf("Can't write `%s`, out: %s, err: %s\n", p.Name(), out, err) 88 | return false 89 | } 90 | 91 | return true 92 | } 93 | 94 | func (p *page) AST() ([]byte, ast.Node) { 95 | src := p.Content() 96 | if p.ast == nil { 97 | p.ast = xlog.MarkdownConverter().Parser().Parse(text.NewReader([]byte(src))) 98 | } 99 | 100 | return []byte(src), p.ast 101 | } 102 | -------------------------------------------------------------------------------- /extensions/gpg/page_source.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/fs" 7 | "path" 8 | "path/filepath" 9 | 10 | "github.com/emad-elsaid/xlog" 11 | ) 12 | 13 | type encryptedPages struct{} 14 | 15 | func (p *encryptedPages) Page(name string) xlog.Page { 16 | if len(gpgId) == 0 { 17 | return nil 18 | } 19 | 20 | pg := page{ 21 | name: name, 22 | } 23 | if pg.Exists() { 24 | return &pg 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func (p *encryptedPages) Each(ctx context.Context, f func(xlog.Page)) { 31 | if len(gpgId) == 0 { 32 | return 33 | } 34 | 35 | filepath.WalkDir(".", func(name string, d fs.DirEntry, err error) error { 36 | select { 37 | 38 | case <-ctx.Done(): 39 | return errors.New("context stopped") 40 | 41 | default: 42 | lastExt := path.Ext(name) 43 | basename := name[:len(name)-len(lastExt)] 44 | secondExt := path.Ext(basename) 45 | ext := secondExt + lastExt 46 | basename = name[:len(name)-len(ext)] 47 | 48 | if EXT == ext { 49 | f(&page{ 50 | name: basename, 51 | }) 52 | break 53 | } 54 | 55 | } 56 | 57 | return nil 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /extensions/hashtags/templates/hashtag-pages-grid.html: -------------------------------------------------------------------------------- 1 | {{ template "pages-grid" .pages }} 2 | -------------------------------------------------------------------------------- /extensions/hashtags/templates/hashtag-pages.html: -------------------------------------------------------------------------------- 1 | {{ template "pages" .pages }} 2 | -------------------------------------------------------------------------------- /extensions/hashtags/templates/related-hashtags-pages.html: -------------------------------------------------------------------------------- 1 | {{ if .pages }} 2 |

See Also

3 | {{ template "pages" .pages }} 4 | {{ end }} 5 | -------------------------------------------------------------------------------- /extensions/hashtags/templates/tag.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 | 3 | {{ template "pages" .pages }} 4 | 5 | {{ template "footer" . }} 6 | -------------------------------------------------------------------------------- /extensions/hashtags/templates/tags.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 | 3 |
4 | 5 | {{ range $tag, $pages := .tags }} 6 |

7 | {{$tag}} 8 |

9 | {{ template "pages" $pages }} 10 | {{ end }} 11 | 12 |
13 | 14 | {{ template "footer" . }} 15 | -------------------------------------------------------------------------------- /extensions/heading/renderer.go: -------------------------------------------------------------------------------- 1 | package heading 2 | 3 | import ( 4 | "fmt" 5 | 6 | . "github.com/emad-elsaid/xlog" 7 | "github.com/yuin/goldmark/ast" 8 | "github.com/yuin/goldmark/renderer" 9 | "github.com/yuin/goldmark/renderer/html" 10 | "github.com/yuin/goldmark/util" 11 | ) 12 | 13 | func init() { 14 | RegisterExtension(Heading{}) 15 | } 16 | 17 | type Heading struct{} 18 | 19 | func (Heading) Name() string { return "heading" } 20 | func (Heading) Init() { 21 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 22 | util.Prioritized(&headingRenderer{}, 0), 23 | )) 24 | } 25 | 26 | type headingRenderer struct{} 27 | 28 | func (s *headingRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 29 | reg.Register(ast.KindHeading, s.render) 30 | } 31 | 32 | func (s *headingRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 33 | n := node.(*ast.Heading) 34 | if entering { 35 | _, _ = w.WriteString("') 41 | } else { 42 | 43 | if id, ok := node.AttributeString("id"); ok { 44 | w.WriteString(fmt.Sprintf(` `, id)) 45 | } 46 | 47 | _, _ = w.WriteString("\n") 50 | } 51 | return ast.WalkContinue, nil 52 | } 53 | -------------------------------------------------------------------------------- /extensions/hotreload/hotreload.go: -------------------------------------------------------------------------------- 1 | package hotreload 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "log/slog" 7 | "sync" 8 | 9 | _ "embed" 10 | 11 | . "github.com/emad-elsaid/xlog" 12 | "github.com/gorilla/websocket" 13 | ) 14 | 15 | var ( 16 | upgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024} 17 | clients = make(map[*websocket.Conn]bool) 18 | clientsMutex sync.Mutex 19 | ) 20 | 21 | func init() { 22 | RegisterExtension(Hotreload{}) 23 | } 24 | 25 | type Hotreload struct{} 26 | 27 | func (Hotreload) Name() string { return "hotreload" } 28 | func (Hotreload) Init() { 29 | if Config.Readonly { 30 | return 31 | } 32 | 33 | Listen(PageChanged, NotifyPageChange) 34 | Get(`/+/hotreload`, handleWebSocket) 35 | RegisterWidget(WidgetAfterView, 0, clientWidget) 36 | } 37 | 38 | func NotifyPageChange(p Page) error { 39 | if !p.Exists() { 40 | return nil 41 | } 42 | 43 | message := map[string]string{"url": fmt.Sprintf("/%s", p.Name())} 44 | 45 | clientsMutex.Lock() 46 | defer clientsMutex.Unlock() 47 | 48 | for client := range clients { 49 | err := client.WriteJSON(message) 50 | if err != nil { 51 | client.Close() 52 | delete(clients, client) 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | func handleWebSocket(r Request) Output { 59 | return func(w Response, r Request) { 60 | conn, err := upgrader.Upgrade(w, r, nil) 61 | if err != nil { 62 | slog.Error("Failed to upgrade", "error", err) 63 | BadRequest(err.Error())(w, r) 64 | } 65 | 66 | // keep connection open 67 | go func() { 68 | defer func() { 69 | clientsMutex.Lock() 70 | delete(clients, conn) 71 | clientsMutex.Unlock() 72 | conn.Close() 73 | }() 74 | 75 | for { 76 | mt, _, err := conn.ReadMessage() 77 | if err != nil || mt == websocket.CloseMessage { 78 | break 79 | } 80 | } 81 | }() 82 | 83 | clientsMutex.Lock() 84 | clients[conn] = true 85 | clientsMutex.Unlock() 86 | } 87 | } 88 | 89 | //go:embed script.html 90 | var clientScript string 91 | 92 | func clientWidget(p Page) template.HTML { 93 | return template.HTML(clientScript) 94 | } 95 | -------------------------------------------------------------------------------- /extensions/hotreload/script.html: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /extensions/images/columnize.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "github.com/yuin/goldmark/ast" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/text" 7 | ) 8 | 9 | type columnizeImagesParagraph struct{} 10 | 11 | func (t columnizeImagesParagraph) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { 12 | paragraphs := []*ast.Paragraph{} 13 | 14 | ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { 15 | if !entering { 16 | return ast.WalkContinue, nil 17 | } 18 | 19 | for c := node.FirstChild(); c != nil; c = c.NextSibling() { 20 | n, ok := c.(*ast.Paragraph) 21 | if !ok { 22 | continue 23 | } 24 | 25 | if containsOnlyImages(n) { 26 | paragraphs = append(paragraphs, n) 27 | } 28 | } 29 | 30 | return ast.WalkContinue, nil 31 | }) 32 | 33 | for _, p := range paragraphs { 34 | removeBreaks(p) 35 | replaceWithColumns(p) 36 | } 37 | } 38 | 39 | func containsOnlyImages(n *ast.Paragraph) bool { 40 | if n.ChildCount() < 2 { 41 | return false 42 | } 43 | 44 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { 45 | if c.Kind() != ast.KindImage && c.Kind() != ast.KindText { 46 | return false 47 | } else if t, ok := c.(*ast.Text); ok && !t.SoftLineBreak() { 48 | return false 49 | } 50 | } 51 | 52 | return true 53 | } 54 | 55 | func removeBreaks(n *ast.Paragraph) { 56 | breaks := []*ast.Text{} 57 | 58 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { 59 | if t, ok := c.(*ast.Text); ok { 60 | breaks = append(breaks, t) 61 | } 62 | } 63 | 64 | for _, b := range breaks { 65 | n.RemoveChild(n, b) 66 | } 67 | } 68 | 69 | func replaceWithColumns(n *ast.Paragraph) { 70 | p := n.Parent() 71 | p.ReplaceChild(p, n, &imagesColumns{*n}) 72 | } 73 | -------------------------------------------------------------------------------- /extensions/images/extension.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | func init() { 11 | RegisterExtension(Images{}) 12 | } 13 | 14 | type Images struct{} 15 | 16 | func (Images) Name() string { return "images" } 17 | func (Images) Init() { 18 | MarkdownConverter().Parser().AddOptions( 19 | parser.WithASTTransformers( 20 | util.Prioritized(columnizeImagesParagraph{}, 0), 21 | ), 22 | ) 23 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 24 | util.Prioritized(&imagesColumnsRenderer{}, 0), 25 | )) 26 | } 27 | -------------------------------------------------------------------------------- /extensions/images/node.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import "github.com/yuin/goldmark/ast" 4 | 5 | var KindColumns = ast.NewNodeKind("ImagesColumns") 6 | 7 | type imagesColumns struct { 8 | ast.Paragraph 9 | } 10 | 11 | func (i *imagesColumns) Kind() ast.NodeKind { return KindColumns } 12 | -------------------------------------------------------------------------------- /extensions/images/renderer.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/ast" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | type imagesColumnsRenderer struct{} 11 | 12 | func (s *imagesColumnsRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 13 | reg.Register(KindColumns, s.render) 14 | } 15 | 16 | func (s *imagesColumnsRenderer) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 17 | if entering { 18 | w.WriteString(`
`) 19 | 20 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { 21 | w.WriteString(`
`) 22 | MarkdownConverter().Renderer().Render(w, source, c) 23 | w.WriteString(`
`) 24 | } 25 | 26 | } else { 27 | w.WriteString(`
`) 28 | } 29 | 30 | return ast.WalkSkipChildren, nil 31 | } 32 | -------------------------------------------------------------------------------- /extensions/link_preview/templates/link-preview.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 11 | {{.title}} 12 | 13 | 14 | {{.description}} 15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /extensions/manifest/manifest.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | 7 | . "github.com/emad-elsaid/xlog" 8 | ) 9 | 10 | //go:embed templates 11 | var templates embed.FS 12 | 13 | func init() { 14 | RegisterExtension(Manifest{}) 15 | } 16 | 17 | type Manifest struct{} 18 | 19 | func (Manifest) Name() string { return "manifest" } 20 | func (Manifest) Init() { 21 | Get("/manifest.json", manifest) 22 | RegisterBuildPage("/manifest.json", false) 23 | RegisterWidget(WidgetHead, 1, head) 24 | RegisterTemplate(templates, "templates") 25 | } 26 | 27 | func manifest(r Request) Output { 28 | return Cache(Render("manifest", Locals{"sitename": Config.Sitename})) 29 | } 30 | 31 | func head(Page) template.HTML { 32 | return template.HTML(``) 33 | } 34 | -------------------------------------------------------------------------------- /extensions/manifest/templates/manifest.html: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/web-manifest-combined.json", 3 | "name": "{{.config.Sitename}}", 4 | "short_name": "{{.config.Sitename}}", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "icons": [ 8 | { 9 | "src": "public/logo.png", 10 | "type": "image/png", 11 | "sizes": "any" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /extensions/mathjax/extension.go: -------------------------------------------------------------------------------- 1 | package mathjax 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | func init() { 11 | RegisterExtension(Mathjax{}) 12 | } 13 | 14 | type Mathjax struct{} 15 | 16 | func (Mathjax) Name() string { return "mathjax" } 17 | func (Mathjax) Init() { 18 | RegisterStaticDir(js) 19 | registerBuildFiles() 20 | MarkdownConverter().Parser().AddOptions( 21 | parser.WithInlineParsers( 22 | util.Prioritized(&inlineMathParser{}, 999), 23 | ), 24 | parser.WithBlockParsers( 25 | util.Prioritized(&mathJaxBlockParser{}, 999), 26 | ), 27 | ) 28 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 29 | util.Prioritized(&InlineMathRenderer{startDelim: `\(`, endDelim: `\)`}, 0), 30 | util.Prioritized(&MathBlockRenderer{startDelim: `\[`, endDelim: `\]`}, 0), 31 | )) 32 | } 33 | -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff -------------------------------------------------------------------------------- /extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Zero.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/extensions/mathjax/js/output/chtml/fonts/woff-v2/MathJax_Zero.woff -------------------------------------------------------------------------------- /extensions/mathjax/nodes.go: -------------------------------------------------------------------------------- 1 | package mathjax 2 | 3 | import ( 4 | "github.com/yuin/goldmark/ast" 5 | "github.com/yuin/goldmark/util" 6 | ) 7 | 8 | type InlineMath struct { 9 | ast.BaseInline 10 | } 11 | 12 | func (n *InlineMath) IsBlank(source []byte) bool { 13 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { 14 | text := c.(*ast.Text).Segment 15 | if !util.IsBlank(text.Value(source)) { 16 | return false 17 | } 18 | } 19 | return true 20 | } 21 | 22 | func (n *InlineMath) Dump(source []byte, level int) { 23 | ast.DumpHelper(n, source, level, nil, nil) 24 | } 25 | 26 | var KindInlineMath = ast.NewNodeKind("InlineMath") 27 | 28 | func (n *InlineMath) Kind() ast.NodeKind { return KindInlineMath } 29 | 30 | type MathBlock struct { 31 | ast.BaseBlock 32 | } 33 | 34 | var KindMathBlock = ast.NewNodeKind("MathBLock") 35 | 36 | func (n *MathBlock) Dump(source []byte, level int) { 37 | ast.DumpHelper(n, source, level, nil, nil) 38 | } 39 | 40 | func (n *MathBlock) Kind() ast.NodeKind { return KindMathBlock } 41 | func (n *MathBlock) IsRaw() bool { return true } 42 | -------------------------------------------------------------------------------- /extensions/mathjax/renderer.go: -------------------------------------------------------------------------------- 1 | package mathjax 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "io/fs" 7 | 8 | . "github.com/emad-elsaid/xlog" 9 | "github.com/yuin/goldmark/ast" 10 | "github.com/yuin/goldmark/renderer" 11 | "github.com/yuin/goldmark/util" 12 | ) 13 | 14 | //go:embed js 15 | var js embed.FS 16 | 17 | const script = ` 18 | 27 | ` 28 | 29 | func registerBuildFiles() { 30 | fs.WalkDir(js, ".", func(path string, d fs.DirEntry, err error) error { 31 | if err != nil { 32 | return err 33 | } 34 | 35 | if d.IsDir() { 36 | return nil 37 | } 38 | 39 | RegisterBuildPage("/"+path, false) 40 | 41 | return nil 42 | }) 43 | } 44 | 45 | type InlineMathRenderer struct { 46 | startDelim string 47 | endDelim string 48 | } 49 | 50 | func (r *InlineMathRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 51 | reg.Register(KindInlineMath, r.renderInlineMath) 52 | } 53 | 54 | func (r *InlineMathRenderer) renderInlineMath(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 55 | if entering { 56 | _, _ = w.WriteString(`` + r.startDelim) 57 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { 58 | segment := c.(*ast.Text).Segment 59 | value := segment.Value(source) 60 | if bytes.HasSuffix(value, []byte("\n")) { 61 | w.Write(value[:len(value)-1]) 62 | if c != n.LastChild() { 63 | w.Write([]byte(" ")) 64 | } 65 | } else { 66 | w.Write(value) 67 | } 68 | } 69 | return ast.WalkSkipChildren, nil 70 | } 71 | w.WriteString(r.endDelim + `` + script) 72 | return ast.WalkContinue, nil 73 | } 74 | 75 | type MathBlockRenderer struct { 76 | startDelim string 77 | endDelim string 78 | } 79 | 80 | func (r *MathBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 81 | reg.Register(KindMathBlock, r.renderMathBlock) 82 | } 83 | 84 | func (r *MathBlockRenderer) renderMathBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 85 | n := node.(*MathBlock) 86 | if entering { 87 | _, _ = w.WriteString(`

` + r.startDelim) 88 | l := n.Lines().Len() 89 | for i := 0; i < l; i++ { 90 | line := n.Lines().At(i) 91 | w.Write(line.Value(source)) 92 | } 93 | } else { 94 | _, _ = w.WriteString(r.endDelim + `

` + "\n" + script) 95 | } 96 | return ast.WalkContinue, nil 97 | } 98 | -------------------------------------------------------------------------------- /extensions/mermaid/main.go: -------------------------------------------------------------------------------- 1 | package mermaid 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | 7 | _ "embed" 8 | 9 | . "github.com/emad-elsaid/xlog" 10 | shortcode "github.com/emad-elsaid/xlog/extensions/shortcode" 11 | ) 12 | 13 | func init() { 14 | RegisterExtension(Mermaid{}) 15 | } 16 | 17 | type Mermaid struct{} 18 | 19 | func (Mermaid) Name() string { return "mermaid" } 20 | func (Mermaid) Init() { 21 | shortcode.RegisterShortCode("mermaid", shortcode.ShortCode{Render: renderer}) 22 | } 23 | 24 | //go:embed script.html 25 | var script string 26 | 27 | const pre = `
%s
` 28 | 29 | func renderer(md Markdown) template.HTML { 30 | html := fmt.Sprintf(pre, md) 31 | return template.HTML(html + script) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /extensions/mermaid/script.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /extensions/photos/templates/photo.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /extensions/photos/templates/photos-grid.html: -------------------------------------------------------------------------------- 1 | {{ range .photos }} 2 | 3 | 4 | 5 | {{ end }} 6 | -------------------------------------------------------------------------------- /extensions/photos/templates/photos.html: -------------------------------------------------------------------------------- 1 |
2 | {{ range .photos }} 3 | 4 |
5 |
6 | 7 | 8 | 9 |
10 | 11 | {{ with .Name }} 12 |
13 | {{.}} 14 |
15 | {{ end }} 16 | 17 |
18 |
19 | 20 | {{ end }} 21 |
22 | -------------------------------------------------------------------------------- /extensions/recent/recent.go: -------------------------------------------------------------------------------- 1 | package recent 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "slices" 7 | "strings" 8 | 9 | _ "embed" 10 | 11 | . "github.com/emad-elsaid/xlog" 12 | ) 13 | 14 | //go:embed templates 15 | var templates embed.FS 16 | 17 | func init() { 18 | RegisterExtension(Recent{}) 19 | } 20 | 21 | type Recent struct{} 22 | 23 | func (Recent) Name() string { return "recent" } 24 | func (Recent) Init() { 25 | Get(`/+/recent`, recentHandler) 26 | RegisterBuildPage("/+/recent", true) 27 | RegisterTemplate(templates, "templates") 28 | RegisterLink(func(Page) []Command { return []Command{links{}} }) 29 | } 30 | 31 | func recentHandler(r Request) Output { 32 | rp := Pages(r.Context()) 33 | slices.SortFunc(rp, func(a, b Page) int { 34 | if modtime := b.ModTime().Compare(a.ModTime()); modtime != 0 { 35 | return modtime 36 | } 37 | 38 | return strings.Compare(a.Name(), b.Name()) 39 | }) 40 | 41 | return Render("recent", Locals{ 42 | "page": DynamicPage{NameVal: "Recent"}, 43 | "pages": rp, 44 | }) 45 | } 46 | 47 | type links struct{} 48 | 49 | func (l links) Icon() string { return "fa-solid fa-clock-rotate-left" } 50 | func (l links) Name() string { return "Recent" } 51 | func (l links) Attrs() map[template.HTMLAttr]any { 52 | return map[template.HTMLAttr]any{ 53 | "href": "/+/recent", 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /extensions/recent/templates/recent.html: -------------------------------------------------------------------------------- 1 | {{- template "header" . }} 2 | {{- template "pages" .pages }} 3 | {{- template "footer" . }} 4 | -------------------------------------------------------------------------------- /extensions/rtl/rtl.go: -------------------------------------------------------------------------------- 1 | package rtl 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/ast" 6 | "github.com/yuin/goldmark/parser" 7 | "github.com/yuin/goldmark/text" 8 | "github.com/yuin/goldmark/util" 9 | ) 10 | 11 | func init() { 12 | RegisterExtension(RTL{}) 13 | } 14 | 15 | type RTL struct{} 16 | 17 | func (RTL) Name() string { return "rtl" } 18 | func (RTL) Init() { 19 | MarkdownConverter().Parser().AddOptions( 20 | parser.WithASTTransformers( 21 | util.Prioritized(addDirAuto{}, 0), 22 | ), 23 | ) 24 | } 25 | 26 | type addDirAuto struct{} 27 | 28 | func (t addDirAuto) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { 29 | tags := []ast.Node{} 30 | 31 | ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { 32 | kind := node.Kind() 33 | if kind == ast.KindParagraph || 34 | kind == ast.KindHeading || 35 | kind == ast.KindList || 36 | kind == ast.KindBlockquote { 37 | tags = append(tags, node) 38 | } 39 | 40 | return ast.WalkContinue, nil 41 | }) 42 | 43 | for _, t := range tags { 44 | t.SetAttributeString("dir", []byte("auto")) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /extensions/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "html/template" 7 | "regexp" 8 | 9 | _ "embed" 10 | 11 | . "github.com/emad-elsaid/xlog" 12 | ) 13 | 14 | const MIN_SEARCH_KEYWORD = 3 15 | 16 | //go:embed templates 17 | var templates embed.FS 18 | 19 | func init() { 20 | RegisterExtension(Search{}) 21 | } 22 | 23 | type Search struct{} 24 | 25 | func (Search) Name() string { return "search" } 26 | func (Search) Init() { 27 | if Config.Readonly { 28 | return 29 | } 30 | 31 | RequireHTMX() 32 | Get(`/+/search`, searchFormHandler) 33 | Get(`/+/search-result`, searchResultHandler) 34 | RegisterWidget("search", 0, searchWidget) 35 | RegisterTemplate(templates, "templates") 36 | } 37 | 38 | func searchWidget(Page) template.HTML { 39 | return Partial("search", nil) 40 | } 41 | 42 | func searchFormHandler(r Request) Output { 43 | return Render("search-form", Locals{ 44 | "page": DynamicPage{NameVal: "Create"}, 45 | "results": search(r.Context(), r.FormValue("q")), 46 | }) 47 | } 48 | 49 | func searchResultHandler(r Request) Output { 50 | return Render("search-result", Locals{ 51 | "results": search(r.Context(), r.FormValue("q")), 52 | }) 53 | } 54 | 55 | type searchResult struct { 56 | Page Page 57 | Line string 58 | } 59 | 60 | func search(ctx context.Context, keyword string) []*searchResult { 61 | results := []*searchResult{} 62 | if len(keyword) < MIN_SEARCH_KEYWORD { 63 | return results 64 | } 65 | 66 | reg := regexp.MustCompile(`(?imU)^(.*` + regexp.QuoteMeta(keyword) + `.*)$`) 67 | 68 | return MapPage(ctx, func(p Page) *searchResult { 69 | match := reg.FindString(p.Name()) 70 | if len(match) > 0 { 71 | return &searchResult{ 72 | Page: p, 73 | Line: "Matches the file name", 74 | } 75 | } 76 | 77 | match = reg.FindString(string(p.Content())) 78 | if len(match) > 0 { 79 | return &searchResult{ 80 | Page: p, 81 | Line: match, 82 | } 83 | } 84 | 85 | return nil 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /extensions/search/templates/search-form.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |
4 | 16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | 27 | 28 | {{ template "footer" . }} 29 | -------------------------------------------------------------------------------- /extensions/search/templates/search-result.html: -------------------------------------------------------------------------------- 1 | {{ range .results }} 2 |
3 | 4 | 5 | {{ with .Page }} 6 | {{ emoji . }} 7 | {{ $props := properties . }} 8 | {{ with $props.title }} {{ .Value }} {{ else }} {{ .Name }} {{ end }} 9 | {{ end }} 10 | 11 | {{.Line}} 12 | 13 |
14 | {{ end }} 15 | -------------------------------------------------------------------------------- /extensions/search/templates/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /extensions/shortcode/ShortCode.md: -------------------------------------------------------------------------------- 1 | ![](/docs/public/shortcode.png) 2 | #extension 3 | 4 | Xlog extension **shortcode** allow using blocks with custom language code that can render the content of the block with custom function 5 | 6 | For example rendering an alert can use two different formats 7 | 8 | # Short format 9 | 10 |
11 | /alert this is important
12 | 
13 | 14 | /alert this is important 15 | 16 | # Long format 17 | 18 |
19 | ```alert
20 | this is important
21 | ```
22 | 
23 | 24 | ```alert 25 | this is important 26 | ``` 27 | 28 | # Default blocks 29 | 30 | Shortcode extension includes couple default blocks: 31 | 32 | ## /alert 33 | 34 | ```alert 35 | Computer science is the study of computation, automation, and information. Computer science spans theoretical disciplines (such as algorithms, theory of computation, information theory, and automation) to practical disciplines (including the design and implementation of hardware and software). Computer science is generally considered an area of academic research and distinct from computer programming. 36 | ``` 37 | 38 | ## /info 39 | 40 | ```info 41 | Computer science is the study of computation, automation, and information. Computer science spans theoretical disciplines (such as algorithms, theory of computation, information theory, and automation) to practical disciplines (including the design and implementation of hardware and software). Computer science is generally considered an area of academic research and distinct from computer programming. 42 | ``` 43 | 44 | ## /success 45 | 46 | ```success 47 | Computer science is the study of computation, automation, and information. Computer science spans theoretical disciplines (such as algorithms, theory of computation, information theory, and automation) to practical disciplines (including the design and implementation of hardware and software). Computer science is generally considered an area of academic research and distinct from computer programming. 48 | ``` 49 | 50 | ## /warning 51 | 52 | ```warning 53 | Computer science is the study of computation, automation, and information. Computer science spans theoretical disciplines (such as algorithms, theory of computation, information theory, and automation) to practical disciplines (including the design and implementation of hardware and software). Computer science is generally considered an area of academic research and distinct from computer programming. 54 | ``` 55 | -------------------------------------------------------------------------------- /extensions/shortcode/extension.go: -------------------------------------------------------------------------------- 1 | package shortcode 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | func init() { 11 | RegisterExtension(ShortCodeEx{}) 12 | } 13 | 14 | type ShortCodeEx struct{} 15 | 16 | func (ShortCodeEx) Name() string { return "shortcode" } 17 | func (ShortCodeEx) Init() { 18 | MarkdownConverter().Parser().AddOptions(parser.WithBlockParsers( 19 | util.Prioritized(&shortCodeParser{}, 0), 20 | )) 21 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 22 | util.Prioritized(&shortCodeRenderer{}, 0), 23 | )) 24 | MarkdownConverter().Parser().AddOptions( 25 | parser.WithASTTransformers( 26 | util.Prioritized(transformShortCodeBlocks{}, 0), 27 | ), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /extensions/shortcode/node.go: -------------------------------------------------------------------------------- 1 | package shortcode 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/yuin/goldmark/ast" 7 | ) 8 | 9 | var KindShortCode = ast.NewNodeKind("ShortCode") 10 | 11 | type ShortCodeNode struct { 12 | ast.BaseBlock 13 | start int 14 | end int 15 | fun ShortCode 16 | } 17 | 18 | func (s *ShortCodeNode) Dump(source []byte, level int) { 19 | m := map[string]string{ 20 | "value": fmt.Sprintf("%#v", s), 21 | } 22 | ast.DumpHelper(s, source, level, m, nil) 23 | } 24 | 25 | func (h *ShortCodeNode) Kind() ast.NodeKind { 26 | return KindShortCode 27 | } 28 | 29 | var KindShortCodeBlock = ast.NewNodeKind("ShortCodeBlock") 30 | 31 | type ShortCodeBlock struct { 32 | ast.FencedCodeBlock 33 | fun ShortCode 34 | } 35 | 36 | func (s *ShortCodeBlock) Kind() ast.NodeKind { 37 | return KindShortCodeBlock 38 | } 39 | -------------------------------------------------------------------------------- /extensions/shortcode/parser.go: -------------------------------------------------------------------------------- 1 | package shortcode 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/yuin/goldmark/ast" 7 | "github.com/yuin/goldmark/parser" 8 | "github.com/yuin/goldmark/text" 9 | ) 10 | 11 | const trigger = '/' 12 | 13 | type shortCodeParser struct{} 14 | 15 | func (s *shortCodeParser) Trigger() []byte { 16 | return []byte{trigger} 17 | } 18 | 19 | func (s *shortCodeParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { 20 | l, seg := reader.PeekLine() 21 | line := string(l) 22 | if len(line) == 0 || line[0] != trigger { 23 | return nil, parser.Close 24 | } 25 | 26 | endOfShortcode := strings.IndexAny(line, " \n") 27 | if endOfShortcode == -1 { 28 | endOfShortcode = len(line) 29 | } 30 | 31 | firstWord := line[1:endOfShortcode] 32 | var processor ShortCode 33 | var ok bool 34 | if processor, ok = shortcodes[firstWord]; !ok { 35 | return nil, parser.Close 36 | } 37 | 38 | reader.AdvanceLine() 39 | 40 | firstSpace := strings.IndexAny(line, " ") 41 | if firstSpace == -1 { 42 | return &ShortCodeNode{ 43 | start: seg.Stop, 44 | end: seg.Stop, 45 | fun: processor, 46 | }, parser.Close 47 | } 48 | 49 | return &ShortCodeNode{ 50 | start: seg.Start + endOfShortcode, 51 | end: seg.Stop, 52 | fun: processor, 53 | }, parser.Close 54 | } 55 | 56 | func (s *shortCodeParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { 57 | return parser.Close 58 | } 59 | 60 | func (s *shortCodeParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {} 61 | func (s *shortCodeParser) CanInterruptParagraph() bool { return true } 62 | func (s *shortCodeParser) CanAcceptIndentedLine() bool { return false } 63 | -------------------------------------------------------------------------------- /extensions/shortcode/parser_test.go: -------------------------------------------------------------------------------- 1 | package shortcode_test 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "testing" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | "github.com/emad-elsaid/xlog/extensions/shortcode" 10 | ) 11 | 12 | func TestShortCode(t *testing.T) { 13 | tcs := []struct { 14 | name string 15 | input string 16 | handlerOutput string 17 | output string 18 | }{ 19 | { 20 | name: "page with one line", 21 | input: "/test", 22 | handlerOutput: "output", 23 | output: "output", 24 | }, 25 | { 26 | name: "short code with new line before it", 27 | input: "\n/test", 28 | handlerOutput: "output", 29 | output: "output", 30 | }, 31 | { 32 | name: "short code with new line after it", 33 | input: "/test\n", 34 | handlerOutput: "output", 35 | output: "output", 36 | }, 37 | { 38 | name: "short code with new line before and after it", 39 | input: "\n/test\n", 40 | handlerOutput: "output", 41 | output: "output", 42 | }, 43 | { 44 | name: "short code with space after", 45 | input: "/test ", 46 | handlerOutput: "output", 47 | output: "output", 48 | }, 49 | { 50 | name: "two short codes", 51 | input: "/test\n\n/test", 52 | handlerOutput: "output", 53 | output: "outputoutput", 54 | }, 55 | } 56 | 57 | for _, tc := range tcs { 58 | t.Run(tc.name, func(t *testing.T) { 59 | handler := func(xlog.Markdown) template.HTML { return template.HTML(tc.handlerOutput) } 60 | shortcode.RegisterShortCode("test", shortcode.ShortCode{Render: handler, Default: ""}) 61 | 62 | output := bytes.NewBufferString("") 63 | xlog.MarkdownConverter().Convert([]byte(tc.input), output) 64 | if output.String() != tc.output { 65 | t.Errorf("input: %s\nexpected: %s\noutput: %s", tc.input, tc.output, output.String()) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /extensions/shortcode/renderer.go: -------------------------------------------------------------------------------- 1 | package shortcode 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/ast" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | type shortCodeRenderer struct{} 11 | 12 | func (s *shortCodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 13 | reg.Register(KindShortCode, s.render) 14 | reg.Register(KindShortCodeBlock, s.renderBlock) 15 | } 16 | 17 | func (s *shortCodeRenderer) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 18 | if !entering { 19 | return ast.WalkContinue, nil 20 | } 21 | 22 | node, ok := n.(*ShortCodeNode) 23 | if !ok { 24 | return ast.WalkContinue, nil 25 | } 26 | 27 | content := source[node.start:node.end] 28 | output := node.fun.Render(Markdown(content)) 29 | w.Write([]byte(output)) 30 | 31 | return ast.WalkContinue, nil 32 | } 33 | 34 | func (s *shortCodeRenderer) renderBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 35 | if !entering { 36 | return ast.WalkContinue, nil 37 | } 38 | 39 | node, ok := n.(*ShortCodeBlock) 40 | if !ok { 41 | return ast.WalkContinue, nil 42 | } 43 | 44 | lines := node.Lines() 45 | content := "" 46 | for i := 0; i < lines.Len(); i++ { 47 | line := lines.At(i) 48 | content += string(line.Value(source)) 49 | } 50 | 51 | output := node.fun.Render(Markdown(content)) 52 | w.Write([]byte(output)) 53 | 54 | return ast.WalkContinue, nil 55 | } 56 | -------------------------------------------------------------------------------- /extensions/shortcode/shortcode.go: -------------------------------------------------------------------------------- 1 | package shortcode 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | 8 | . "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | type ShortCode struct { 12 | Render func(Markdown) template.HTML 13 | Default string 14 | } 15 | 16 | func render(i Markdown) string { 17 | var b bytes.Buffer 18 | MarkdownConverter().Convert([]byte(i), &b) 19 | return b.String() 20 | } 21 | 22 | func container(cls string, content Markdown) template.HTML { 23 | tpl := `
%s
` 24 | return template.HTML(fmt.Sprintf(tpl, cls, render(content))) 25 | } 26 | 27 | var shortcodes = map[string]ShortCode{ 28 | "info": {Render: func(c Markdown) template.HTML { return container("is-info", c) }}, 29 | "success": {Render: func(c Markdown) template.HTML { return container("is-success", c) }}, 30 | "warning": {Render: func(c Markdown) template.HTML { return container("is-warning", c) }}, 31 | "alert": {Render: func(c Markdown) template.HTML { return container("is-danger", c) }}, 32 | } 33 | 34 | func RegisterShortCode(name string, shortcode ShortCode) { 35 | shortcodes[name] = shortcode 36 | } 37 | -------------------------------------------------------------------------------- /extensions/shortcode/transformer.go: -------------------------------------------------------------------------------- 1 | package shortcode 2 | 3 | import ( 4 | "github.com/yuin/goldmark/ast" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/text" 7 | ) 8 | 9 | type transformShortCodeBlocks struct{} 10 | 11 | func (t transformShortCodeBlocks) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { 12 | source := reader.Source() 13 | blocks := []*ast.FencedCodeBlock{} 14 | 15 | ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { 16 | if !entering { 17 | return ast.WalkContinue, nil 18 | } 19 | 20 | for c := node.FirstChild(); c != nil; c = c.NextSibling() { 21 | n, ok := c.(*ast.FencedCodeBlock) 22 | if !ok { 23 | continue 24 | } 25 | 26 | if _, ok := shortcodes[string(n.Language(source))]; !ok { 27 | continue 28 | } 29 | 30 | blocks = append(blocks, n) 31 | } 32 | 33 | return ast.WalkContinue, nil 34 | }) 35 | 36 | for _, b := range blocks { 37 | replacement := ShortCodeBlock{ 38 | FencedCodeBlock: *b, 39 | fun: shortcodes[string(b.Language(source))], 40 | } 41 | 42 | parent := b.Parent() 43 | parent.ReplaceChild(parent, b, &replacement) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /extensions/sitemap/sitemap.go: -------------------------------------------------------------------------------- 1 | package sitemap 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | . "github.com/emad-elsaid/xlog" 10 | ) 11 | 12 | var SITEMAP_DOMAIN string 13 | 14 | func init() { 15 | flag.StringVar(&SITEMAP_DOMAIN, "sitemap.domain", "", "domain name without protocol or trailing / to use for sitemap loc") 16 | RegisterExtension(Sitemap{}) 17 | } 18 | 19 | type Sitemap struct{} 20 | 21 | func (Sitemap) Name() string { return "sitemap" } 22 | func (Sitemap) Init() { 23 | Get(`/sitemap.xml`, handler) 24 | RegisterBuildPage("/sitemap.xml", false) 25 | } 26 | 27 | func handler(r Request) Output { 28 | output := []string{``} 29 | 30 | output = append(output, MapPage(r.Context(), func(p Page) string { 31 | return fmt.Sprintf("https://%s/%s", SITEMAP_DOMAIN, url.PathEscape(p.Name())) 32 | })...) 33 | 34 | output = append(output, ``) 35 | 36 | return PlainText(strings.Join(output, "\n")) 37 | } 38 | -------------------------------------------------------------------------------- /extensions/sql_table/extension.go: -------------------------------------------------------------------------------- 1 | package sql_table 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | "fmt" 7 | "html/template" 8 | 9 | "github.com/emad-elsaid/types" 10 | "github.com/emad-elsaid/xlog" 11 | east "github.com/yuin/goldmark/extension/ast" 12 | ) 13 | 14 | //go:embed js 15 | var js embed.FS 16 | 17 | var sqlTableThreshold int 18 | 19 | func init() { 20 | flag.IntVar(&sqlTableThreshold, "sql-table.threshold", 100, "If a table rows is more than this threshold it'll allow users to query it with SQL") 21 | xlog.RegisterExtension(Extension{}) 22 | } 23 | 24 | type Extension struct{} 25 | 26 | func (Extension) Name() string { 27 | return "sql_table" 28 | } 29 | 30 | func (Extension) Init() { 31 | xlog.RegisterWidget(xlog.WidgetAfterView, 1, script) 32 | } 33 | 34 | func script(p xlog.Page) template.HTML { 35 | if p == nil { 36 | return "" 37 | } 38 | 39 | _, a := p.AST() 40 | if a == nil { 41 | return "" 42 | } 43 | 44 | tables := xlog.FindAllInAST[*east.Table](a) 45 | if len(tables) == 0 { 46 | return "" 47 | } 48 | 49 | largeTableFound := types.Slice[*east.Table](tables).Any(func(t *east.Table) bool { 50 | return len(xlog.FindAllInAST[*east.TableRow](t)) >= sqlTableThreshold 51 | }) 52 | if !largeTableFound { 53 | return "" 54 | } 55 | 56 | o, _ := js.ReadFile("js/sql_table.html") 57 | o = append(o, []byte(fmt.Sprintf("", sqlTableThreshold))...) 58 | 59 | return template.HTML(o) 60 | } 61 | -------------------------------------------------------------------------------- /extensions/toc/extension.go: -------------------------------------------------------------------------------- 1 | package toc 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | 7 | "github.com/emad-elsaid/xlog" 8 | gtoc "go.abhg.dev/goldmark/toc" 9 | ) 10 | 11 | //go:embed templates 12 | var templates embed.FS 13 | 14 | func init() { 15 | xlog.RegisterExtension(Extension{}) 16 | } 17 | 18 | type Extension struct{} 19 | 20 | func (Extension) Name() string { return "toc" } 21 | func (Extension) Init() { 22 | xlog.RegisterWidget(xlog.WidgetBeforeView, 0, TOC) 23 | xlog.RegisterTemplate(templates, "templates") 24 | } 25 | 26 | func TOC(p xlog.Page) template.HTML { 27 | if p == nil { 28 | return "" 29 | } 30 | 31 | src, doc := p.AST() 32 | if src == nil || doc == nil { 33 | return "" 34 | } 35 | 36 | tree, err := gtoc.Inspect(doc, src, gtoc.MaxDepth(1)) 37 | if err != nil { 38 | return "" 39 | } 40 | 41 | if len(tree.Items) == 0 { 42 | return "" 43 | } 44 | 45 | return xlog.Partial("toc", xlog.Locals{"tree": tree}) 46 | } 47 | -------------------------------------------------------------------------------- /extensions/toc/templates/toc.html: -------------------------------------------------------------------------------- 1 | {{ define "toc-item" }} 2 |
  • 3 | {{ printf "%s" .Title | raw }} 4 | {{ with .Items }} 5 | 10 | {{ end }} 11 |
  • 12 | {{ end }} 13 | 14 | {{ with .tree.Items }} 15 | 39 | 40 | 77 | 78 | 79 | 89 | {{ end }} 90 | -------------------------------------------------------------------------------- /extensions/todo/extension.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | . "github.com/emad-elsaid/xlog" 5 | "github.com/yuin/goldmark/renderer" 6 | "github.com/yuin/goldmark/util" 7 | ) 8 | 9 | func init() { 10 | RegisterExtension(TODO{}) 11 | } 12 | 13 | type TODO struct{} 14 | 15 | func (TODO) Name() string { return "todo" } 16 | func (TODO) Init() { 17 | MarkdownConverter().Renderer().AddOptions(renderer.WithNodeRenderers( 18 | util.Prioritized(&TaskCheckBoxHTMLRenderer{}, 0), 19 | )) 20 | 21 | if !Config.Readonly { 22 | RequireHTMX() 23 | Post(`/+/todo`, toggleHandler) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extensions/todo/handler.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | 8 | . "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | var taskListRegexp = regexp.MustCompile(`^\[([\sxX])\]\s*`) 12 | 13 | func toggleHandler(r Request) Output { 14 | page := NewPage(r.FormValue("page")) 15 | if page == nil || !page.Exists() { 16 | return NotFound(fmt.Sprintf("page: %s not found", r.FormValue("page"))) 17 | } 18 | 19 | pos, err := strconv.ParseInt(r.FormValue("pos"), 10, 64) 20 | if err != nil { 21 | return BadRequest("Pos value is incorrect, " + err.Error()) 22 | } 23 | 24 | content := string(page.Content()) 25 | if int(pos) >= len(content) { 26 | return BadRequest("pos is longer than the content") 27 | } 28 | 29 | replacement := "[ ] " 30 | if len(r.FormValue("checked")) > 0 { 31 | replacement = "[x] " 32 | } 33 | 34 | line := content[:pos] + taskListRegexp.ReplaceAllString(content[pos:], replacement) 35 | page.Write(Markdown(line)) 36 | return NoContent() 37 | } 38 | -------------------------------------------------------------------------------- /extensions/todo/renderer.go: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | 7 | . "github.com/emad-elsaid/xlog" 8 | "github.com/yuin/goldmark/ast" 9 | east "github.com/yuin/goldmark/extension/ast" 10 | "github.com/yuin/goldmark/renderer" 11 | "github.com/yuin/goldmark/util" 12 | ) 13 | 14 | type TaskCheckBoxHTMLRenderer struct{} 15 | 16 | func (r *TaskCheckBoxHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 17 | reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox) 18 | } 19 | 20 | func (r *TaskCheckBoxHTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 21 | if !entering { 22 | return ast.WalkContinue, nil 23 | } 24 | 25 | n := node.(*east.TaskCheckBox) 26 | p := n.Parent() 27 | 28 | w.WriteString(` ") 49 | return ast.WalkContinue, nil 50 | } 51 | -------------------------------------------------------------------------------- /extensions/upload_file/record_audio.go: -------------------------------------------------------------------------------- 1 | package upload_file 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/url" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | type RecordAudio struct { 12 | p xlog.Page 13 | } 14 | 15 | func (RecordAudio) Icon() string { return "fa-solid fa-microphone" } 16 | func (RecordAudio) Name() string { return "Record audio" } 17 | func (s RecordAudio) Attrs() map[template.HTMLAttr]any { 18 | link := fmt.Sprintf("/+/upload-file/record-audio-form?page=%s", url.PathEscape(s.p.Name())) 19 | 20 | return map[template.HTMLAttr]any{ 21 | "href": link, 22 | "hx-post": link, 23 | "hx-target": "body", 24 | "hx-swap": "beforeend", 25 | } 26 | } 27 | 28 | func RecordAudioForm(r xlog.Request) xlog.Output { 29 | name := r.FormValue("page") 30 | 31 | return xlog.Render("record-audio", map[string]any{ 32 | "action": "/+/upload-file?page=" + url.QueryEscape(name), 33 | "csrf": xlog.CSRF(r), 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /extensions/upload_file/record_camera.go: -------------------------------------------------------------------------------- 1 | package upload_file 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/url" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | type RecordCamera struct { 12 | p xlog.Page 13 | } 14 | 15 | func (RecordCamera) Icon() string { return "fa-solid fa-video" } 16 | func (RecordCamera) Name() string { return "Record camera" } 17 | func (s RecordCamera) Attrs() map[template.HTMLAttr]any { 18 | link := fmt.Sprintf("/+/upload-file/record-camera-form?page=%s", url.PathEscape(s.p.Name())) 19 | 20 | return map[template.HTMLAttr]any{ 21 | "href": link, 22 | "hx-post": link, 23 | "hx-target": "body", 24 | "hx-swap": "beforeend", 25 | } 26 | } 27 | 28 | func RecordCameraForm(r xlog.Request) xlog.Output { 29 | name := r.FormValue("page") 30 | 31 | return xlog.Render("record-camera", map[string]any{ 32 | "action": "/+/upload-file?page=" + url.QueryEscape(name), 33 | "csrf": xlog.CSRF(r), 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /extensions/upload_file/record_screen.go: -------------------------------------------------------------------------------- 1 | package upload_file 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/url" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | type RecordScreen struct { 12 | p xlog.Page 13 | } 14 | 15 | func (RecordScreen) Icon() string { return "fa-solid fa-desktop" } 16 | func (RecordScreen) Name() string { return "Record screen" } 17 | func (s RecordScreen) Attrs() map[template.HTMLAttr]any { 18 | link := fmt.Sprintf("/+/upload-file/record-screen-form?page=%s", url.PathEscape(s.p.Name())) 19 | 20 | return map[template.HTMLAttr]any{ 21 | "href": link, 22 | "hx-post": link, 23 | } 24 | } 25 | 26 | func RecordScreenForm(r xlog.Request) xlog.Output { 27 | name := r.FormValue("page") 28 | 29 | return xlog.Render("record-screen", map[string]any{ 30 | "action": "/+/upload-file?page=" + url.QueryEscape(name), 31 | "csrf": xlog.CSRF(r), 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /extensions/upload_file/screenshot.go: -------------------------------------------------------------------------------- 1 | package upload_file 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/url" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | type Screenshot struct { 12 | p xlog.Page 13 | } 14 | 15 | func (Screenshot) Icon() string { return "fa-solid fa-camera" } 16 | func (Screenshot) Name() string { return "Screenshot" } 17 | func (s Screenshot) Attrs() map[template.HTMLAttr]any { 18 | link := fmt.Sprintf("/+/upload-file/screenshot-form?page=%s", url.PathEscape(s.p.Name())) 19 | 20 | return map[template.HTMLAttr]any{ 21 | "href": link, 22 | "hx-post": link, 23 | } 24 | } 25 | 26 | func ScreenshotForm(r xlog.Request) xlog.Output { 27 | name := r.FormValue("page") 28 | 29 | return xlog.Render("screenshot", map[string]any{ 30 | "action": "/+/upload-file?page=" + url.QueryEscape(name), 31 | "csrf": xlog.CSRF(r), 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /extensions/upload_file/templates/record-audio.html: -------------------------------------------------------------------------------- 1 | 15 | 44 | -------------------------------------------------------------------------------- /extensions/upload_file/templates/record-camera.html: -------------------------------------------------------------------------------- 1 | 15 | 16 | 45 | -------------------------------------------------------------------------------- /extensions/upload_file/templates/record-screen.html: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /extensions/upload_file/templates/screenshot.html: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /extensions/upload_file/templates/upload.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /extensions/upload_file/upload.go: -------------------------------------------------------------------------------- 1 | package upload_file 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/url" 7 | 8 | "github.com/emad-elsaid/xlog" 9 | ) 10 | 11 | type Upload struct { 12 | p xlog.Page 13 | } 14 | 15 | func (Upload) Icon() string { return "fa-solid fa-file-arrow-up" } 16 | func (Upload) Name() string { return "Upload File" } 17 | func (u Upload) Attrs() map[template.HTMLAttr]any { 18 | link := fmt.Sprintf("/+/upload-file/form?page=%s", url.PathEscape(u.p.Name())) 19 | 20 | return map[template.HTMLAttr]any{ 21 | "href": link, 22 | "hx-post": link, 23 | "hx-target": "body", 24 | "hx-swap": "beforeend", 25 | } 26 | } 27 | 28 | func UploadForm(r xlog.Request) xlog.Output { 29 | name := r.FormValue("page") 30 | 31 | return xlog.Render("upload", map[string]any{ 32 | "action": "/+/upload-file?page=" + url.QueryEscape(name), 33 | "csrf": xlog.CSRF(r), 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | ) 7 | 8 | type Configuration struct { 9 | Source string // path to markdown files directory 10 | Build string // path to write built files 11 | Sitename string // name of knowledgebase 12 | Index string // name of the index page markdown file 13 | NotFoundPage string // name of the index page markdown file 14 | BindAddress string // bind address for the server 15 | Theme string // empty switches between light/dark. setting it forces a theme 16 | CodeStyle string 17 | CsrfCookieName string 18 | DisabledExtensions string 19 | Readonly bool // is xlog in readonly mode 20 | ServeInsecure bool // should the server use https for cookie 21 | } 22 | 23 | var Config Configuration 24 | 25 | func init() { 26 | // Uses current working directory as default value for source flag. If the 27 | // source flag is set by user the program changes working directory to is 28 | // and the rest of the program can use relative paths to access files 29 | cwd, _ := os.Getwd() 30 | flag.StringVar(&Config.Source, "source", cwd, "Directory that will act as a storage") 31 | flag.StringVar(&Config.Build, "build", "", "Build all pages as static site in this directory") 32 | flag.StringVar(&Config.Sitename, "sitename", "XLOG", "Site name is the name that appears on the header beside the logo and in the title tag") 33 | flag.StringVar(&Config.Index, "index", "index", "Index file name used as home page") 34 | flag.StringVar(&Config.NotFoundPage, "notfoundpage", "404", "Custom not found page") 35 | flag.BoolVar(&Config.Readonly, "readonly", false, "Should xlog hide write operations, read-only means all write operations will be disabled") 36 | flag.StringVar(&Config.BindAddress, "bind", "127.0.0.1:3000", "IP and port to bind the web server to") 37 | flag.BoolVar(&Config.ServeInsecure, "serve-insecure", false, "Accept http connections and forward crsf cookie over non secure connections") 38 | flag.StringVar(&Config.CsrfCookieName, "csrf-cookie", "xlog_csrf", "CSRF cookie name") 39 | flag.StringVar(&Config.DisabledExtensions, "disabled-extensions", "", "disable list of extensions by name, comma separated, `all` will disable all extensions") 40 | flag.StringVar(&Config.CodeStyle, "codestyle", "dracula", "code highlighting style name from the list supported by https://pkg.go.dev/github.com/alecthomas/chroma/v2/styles") 41 | flag.StringVar(&Config.Theme, "theme", "", "bulma theme to use. (light, dark). empty value means system preference is used") 42 | } 43 | -------------------------------------------------------------------------------- /fs.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | "os" 8 | "path" 9 | ) 10 | 11 | // return file that exists in one of the FS structs. 12 | // Prioritizing the end of the slice over earlier FSs. 13 | type priorityFS []fs.FS 14 | 15 | func (p priorityFS) Open(name string) (fs.File, error) { 16 | for i := len(p) - 1; i >= 0; i-- { 17 | cf := p[i] 18 | f, err := cf.Open(name) 19 | if err == nil { 20 | return f, err 21 | } 22 | } 23 | 24 | return nil, fs.ErrNotExist 25 | } 26 | 27 | //go:embed public 28 | var assets embed.FS 29 | 30 | var staticDirs = []fs.FS{assets} 31 | 32 | // RegisterStaticDir adds a filesystem to the filesystems list scanned for files 33 | // when serving static files. can be used to add a directory of CSS or JS files 34 | // by extensions 35 | func RegisterStaticDir(f fs.FS) { 36 | staticDirs = append(staticDirs, f) 37 | } 38 | 39 | func staticHandler(r Request) (Output, error) { 40 | wd, err := os.Getwd() 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | staticFSs := http.FS( 46 | priorityFS( 47 | append(staticDirs, os.DirFS(wd)), 48 | ), 49 | ) 50 | 51 | server := http.FileServer(staticFSs) 52 | 53 | cleanPath := path.Clean(r.URL.Path) 54 | 55 | if f, err := staticFSs.Open(cleanPath); err != nil { 56 | return nil, err 57 | } else { 58 | f.Close() 59 | return Cache(server.ServeHTTP), nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emad-elsaid/xlog 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/alecthomas/chroma/v2 v2.13.0 7 | github.com/gorilla/csrf v1.7.3 8 | github.com/yuin/goldmark v1.7.8 9 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 10 | ) 11 | 12 | require ( 13 | github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd 14 | golang.org/x/image v0.18.0 15 | ) 16 | 17 | require ( 18 | github.com/emad-elsaid/memoize v0.0.0-20241119212339-a0b57858a452 19 | github.com/emad-elsaid/types v0.0.4 20 | github.com/gorilla/websocket v1.5.1 21 | github.com/hashicorp/golang-lru/v2 v2.0.7 22 | github.com/rjeczalik/notify v0.9.3 23 | github.com/stretchr/testify v1.10.0 24 | github.com/yuin/goldmark-meta v1.1.0 25 | gitlab.com/greyxor/slogor v1.5.2 26 | go.abhg.dev/goldmark/toc v0.10.0 27 | golang.org/x/sync v0.6.0 28 | gopkg.in/yaml.v3 v3.0.1 29 | ) 30 | 31 | require ( 32 | github.com/davecgh/go-spew v1.1.1 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | golang.org/x/net v0.38.0 // indirect 35 | gopkg.in/yaml.v2 v2.3.0 // indirect 36 | ) 37 | 38 | require ( 39 | github.com/ProtonMail/go-crypto v1.0.0 // indirect 40 | github.com/cloudflare/circl v1.3.7 // indirect 41 | github.com/golang/protobuf v1.5.4 // indirect 42 | github.com/google/go-github/v53 v53.2.0 43 | github.com/google/go-querystring v1.1.0 // indirect 44 | golang.org/x/crypto v0.36.0 // indirect 45 | golang.org/x/oauth2 v0.18.0 46 | golang.org/x/sys v0.31.0 // indirect 47 | google.golang.org/appengine v1.6.8 // indirect 48 | google.golang.org/protobuf v1.33.0 // indirect 49 | ) 50 | 51 | require ( 52 | github.com/dlclark/regexp2 v1.11.0 // indirect 53 | github.com/gorilla/securecookie v1.1.2 // indirect 54 | github.com/yuin/goldmark-emoji v1.0.2 55 | ) 56 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "html/template" 7 | "log/slog" 8 | "os" 9 | "runtime" 10 | "time" 11 | 12 | "github.com/gorilla/csrf" 13 | "gitlab.com/greyxor/slogor" 14 | ) 15 | 16 | // Define the catch all HTTP routes, parse CLI flags and take actions like 17 | // building the static pages and exit, or start the HTTP server 18 | func Start(ctx context.Context) { 19 | runtime.GOMAXPROCS(runtime.NumCPU() * 2) 20 | flag.Parse() 21 | 22 | // Setup logger 23 | level := slogor.SetLevel(slog.LevelDebug) 24 | timeFmt := slogor.SetTimeFormat(time.TimeOnly) 25 | handler := slogor.NewHandler(os.Stderr, level, timeFmt) 26 | logger := slog.New(handler) 27 | slog.SetDefault(logger) 28 | 29 | // if a static site is going to be built then lets also turn on read only 30 | // mode 31 | if len(Config.Build) > 0 { 32 | Config.Readonly = true 33 | } 34 | 35 | if !Config.Readonly { 36 | Listen(PageChanged, clearPagesCache) 37 | Listen(PageDeleted, clearPagesCache) 38 | } 39 | 40 | if err := os.Chdir(Config.Source); err != nil { 41 | slog.Error("Failed to change dir to source", "error", err, "source", Config.Source) 42 | os.Exit(1) 43 | } 44 | 45 | initExtensions() 46 | 47 | Get("/{$}", rootHandler) 48 | Get("/{page...}", getPageHandler) 49 | 50 | if len(Config.Build) > 0 { 51 | if err := build(Config.Build); err != nil { 52 | slog.Error("Failed to build static pages", "error", err) 53 | os.Exit(1) 54 | } 55 | 56 | return 57 | } 58 | 59 | srv := server() 60 | slog.Info("Starting server", "address", Config.BindAddress) 61 | 62 | go func() { 63 | <-ctx.Done() 64 | srv.Close() 65 | }() 66 | 67 | srv.ListenAndServe() 68 | } 69 | 70 | // Redirect to `/index` to render the index page. 71 | func rootHandler(r Request) Output { 72 | return Redirect("/" + Config.Index) 73 | } 74 | 75 | // Shows a page. the page name is the path itself. if the page doesn't exist it 76 | // redirect to edit page otherwise will render it to HTML 77 | func getPageHandler(r Request) Output { 78 | page := NewPage(r.PathValue("page")) 79 | 80 | if page == nil { 81 | return NoContent() 82 | } 83 | 84 | if !page.Exists() { 85 | // if it's a directory get back to home page 86 | if s, err := os.Stat(page.Name()); err == nil && s.IsDir() { 87 | return Redirect(Config.Index) 88 | } 89 | 90 | // if it's a static file serve it 91 | if output, err := staticHandler(r); err == nil { 92 | return output 93 | } 94 | 95 | // if it's readonly mode quit now 96 | if Config.Readonly { 97 | return NotFound("can't find page") 98 | } 99 | 100 | // Allow extensions to handle this page if it's not readonly mode like 101 | // opening an editor or something 102 | Trigger(PageNotFound, page) 103 | 104 | page = DynamicPage{ 105 | NameVal: page.Name(), 106 | RenderFn: func() template.HTML { 107 | str := "Page doesn't exist" 108 | 109 | return template.HTML(str) 110 | }, 111 | } 112 | } 113 | 114 | return Render("page", Locals{ 115 | "page": page, 116 | "csrf": csrf.Token(r), 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/yuin/goldmark/text" 9 | ) 10 | 11 | func TestBanner(t *testing.T) { 12 | tcs := []struct { 13 | name string 14 | path string 15 | content string 16 | expected string 17 | }{ 18 | { 19 | name: "page in root and image is relative implicitly", 20 | path: "home", 21 | content: "![](image.jpg)", 22 | expected: "/image.jpg", 23 | }, 24 | { 25 | name: "page in root and image is relative explicitly", 26 | path: "home", 27 | content: "![](./image.jpg)", 28 | expected: "/image.jpg", 29 | }, 30 | { 31 | name: "page in root and image is relative explicitly in subdir", 32 | path: "home", 33 | content: "![](./images/image.jpg)", 34 | expected: "/images/image.jpg", 35 | }, 36 | { 37 | name: "page in subdir and image is relative implicitly", 38 | path: "posts/home", 39 | content: "![](image.jpg)", 40 | expected: "/posts/image.jpg", 41 | }, 42 | { 43 | name: "page in subdir and image is relative explicitly", 44 | path: "posts/home", 45 | content: "![](./image.jpg)", 46 | expected: "/posts/image.jpg", 47 | }, 48 | { 49 | name: "page in subdir and image is relative explicitly in subdir", 50 | path: "posts/home", 51 | content: "![](./images/image.jpg)", 52 | expected: "/posts/images/image.jpg", 53 | }, 54 | { 55 | name: "page in subdir and image is relative explicitly in parent", 56 | path: "posts/home", 57 | content: "![](../images/image.jpg)", 58 | expected: "/images/image.jpg", 59 | }, 60 | } 61 | 62 | for _, tc := range tcs { 63 | t.Run(tc.name, func(t *testing.T) { 64 | reader := text.NewReader([]byte(tc.content)) 65 | p := page{ 66 | name: tc.path, 67 | lastUpdate: time.Time{}, 68 | ast: MarkdownConverter().Parser().Parse(reader), 69 | content: (*Markdown)(&tc.content), 70 | } 71 | 72 | require.Equal(t, tc.expected, Banner(&p)) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /page_source.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type PageSource interface { 8 | // Page takes a page name and return a Page struct 9 | Page(string) Page 10 | // Each iterates over all pages in the source 11 | Each(context.Context, func(Page)) 12 | } 13 | 14 | var sources = []PageSource{ 15 | newMarkdownFS("."), 16 | } 17 | 18 | func NewPage(name string) (p Page) { 19 | for i := range sources { 20 | p = sources[i].Page(name) 21 | if p != nil && p.Exists() { 22 | return 23 | } 24 | } 25 | 26 | return 27 | } 28 | 29 | func RegisterPageSource(p PageSource) { 30 | sources = append([]PageSource{p}, sources...) 31 | } 32 | -------------------------------------------------------------------------------- /preprocessor.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | // A Preprocessor is a function that takes the whole page content and returns a 4 | // modified version of the content. extensions should define this type and 5 | // register is so that when page is rendered it will execute all of them in 6 | // order like a pipeline each function output is passed as an input to the next. 7 | // at the end the last preprocessor output is then rendered to HTML 8 | type Preprocessor func(Markdown) Markdown 9 | 10 | // List of registered preprocessor functions 11 | var preprocessors = []Preprocessor{} 12 | 13 | // RegisterPreprocessor registers a Preprocessor function. extensions should use this function to 14 | // register a preprocessor. 15 | func RegisterPreprocessor(f Preprocessor) { preprocessors = append(preprocessors, f) } 16 | 17 | // This function take the page content and pass it through all registered 18 | // preprocessors and return the last preprocessor output to the caller 19 | func PreProcess(content Markdown) Markdown { 20 | for _, v := range preprocessors { 21 | content = v(content) 22 | } 23 | 24 | return content 25 | } 26 | -------------------------------------------------------------------------------- /property.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | // Property represent a piece of information about the current page such as last 4 | // update time, number of versions, number of words, reading time...etc 5 | type Property interface { 6 | // Icon returns the fontawesome icon class name or emoji 7 | Icon() string 8 | // Name returns the name of the property 9 | Name() string 10 | // Value returns the value of the property 11 | Value() any 12 | } 13 | 14 | var propsSources = []func(Page) []Property{defaultProps} 15 | 16 | // RegisterProperty registers a function that returns a set of properties for 17 | // the page 18 | func RegisterProperty(a func(Page) []Property) { 19 | propsSources = append(propsSources, a) 20 | } 21 | 22 | // Properties return a list of properties for a page. It executes all functions 23 | // registered with RegisterProperty and collect results in one slice. Can be 24 | // passed to the view to render a page properties 25 | func Properties(p Page) map[string]Property { 26 | ps := map[string]Property{} 27 | for _, source := range propsSources { 28 | for _, pr := range source(p) { 29 | ps[pr.Name()] = pr 30 | } 31 | } 32 | 33 | return ps 34 | } 35 | 36 | type lastUpdateProp struct{ page Page } 37 | 38 | func (a lastUpdateProp) Icon() string { return "fa-solid fa-clock" } 39 | func (a lastUpdateProp) Name() string { return "modified" } 40 | func (a lastUpdateProp) Value() any { return ago(a.page.ModTime()) } 41 | 42 | func defaultProps(p Page) []Property { 43 | if p.ModTime().IsZero() { 44 | return nil 45 | } 46 | 47 | return []Property{ 48 | lastUpdateProp{p}, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/assets/DubaiW23-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/DubaiW23-Bold.woff -------------------------------------------------------------------------------- /public/assets/DubaiW23-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/DubaiW23-Regular.woff -------------------------------------------------------------------------------- /public/assets/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/fa-brands-400.ttf -------------------------------------------------------------------------------- /public/assets/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/fa-brands-400.woff2 -------------------------------------------------------------------------------- /public/assets/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/fa-regular-400.ttf -------------------------------------------------------------------------------- /public/assets/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/fa-regular-400.woff2 -------------------------------------------------------------------------------- /public/assets/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/fa-solid-900.ttf -------------------------------------------------------------------------------- /public/assets/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/fa-solid-900.woff2 -------------------------------------------------------------------------------- /public/assets/inter-all-100-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-100-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-200-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-200-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-300-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-300-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-400-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-400-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-500-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-500-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-600-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-600-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-700-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-700-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-800-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-800-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-all-900-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-all-900-normal.woff -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-100-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-100-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-200-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-200-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-300-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-400-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-400-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-500-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-600-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-600-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-700-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-800-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-800-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-900-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-100-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-100-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-200-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-200-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-300-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-400-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-400-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-500-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-600-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-600-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-700-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-800-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-800-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-cyrillic-ext-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-cyrillic-ext-900-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-100-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-100-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-200-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-200-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-300-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-400-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-400-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-500-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-600-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-600-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-700-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-800-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-800-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-900-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-100-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-100-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-200-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-200-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-300-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-400-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-400-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-500-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-600-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-600-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-700-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-800-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-800-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-greek-ext-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-greek-ext-900-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-100-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-100-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-200-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-200-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-300-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-400-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-400-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-500-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-600-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-600-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-700-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-800-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-800-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-900-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-100-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-100-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-200-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-200-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-300-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-400-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-400-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-500-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-600-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-600-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-700-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-800-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-800-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-latin-ext-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-latin-ext-900-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-100-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-100-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-200-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-200-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-300-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-300-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-400-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-400-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-500-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-500-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-600-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-600-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-700-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-700-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-800-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-800-normal.woff2 -------------------------------------------------------------------------------- /public/assets/inter-vietnamese-900-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/assets/inter-vietnamese-900-normal.woff2 -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/xlog/00ddaff0ffbc9a3e26326e0564a6f66fd1383d65/public/logo.png -------------------------------------------------------------------------------- /renderer.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "sync" 5 | 6 | chroma_html "github.com/alecthomas/chroma/v2/formatters/html" 7 | "github.com/alecthomas/chroma/v2/styles" 8 | "github.com/yuin/goldmark" 9 | emoji "github.com/yuin/goldmark-emoji" 10 | 11 | highlighting "github.com/yuin/goldmark-highlighting/v2" 12 | "github.com/yuin/goldmark/ast" 13 | "github.com/yuin/goldmark/extension" 14 | "github.com/yuin/goldmark/parser" 15 | "github.com/yuin/goldmark/renderer/html" 16 | ) 17 | 18 | // The instance of markdown renderer. this is what takes the page content and 19 | // converts it to HTML. it defines what features to use from goldmark and what 20 | // options to turn on 21 | var MarkdownConverter = sync.OnceValue(func() goldmark.Markdown { 22 | return goldmark.New( 23 | goldmark.WithExtensions( 24 | extension.GFM, 25 | extension.DefinitionList, 26 | extension.Footnote, 27 | extension.Typographer, 28 | highlighting.NewHighlighting( 29 | highlighting.WithCustomStyle(styles.Get(Config.CodeStyle)), 30 | highlighting.WithFormatOptions( 31 | chroma_html.WithLineNumbers(true), 32 | ), 33 | ), 34 | emoji.Emoji, 35 | ), 36 | 37 | goldmark.WithParserOptions( 38 | parser.WithAutoHeadingID(), 39 | ), 40 | 41 | goldmark.WithRendererOptions( 42 | html.WithHardWraps(), 43 | html.WithUnsafe(), 44 | ), 45 | ) 46 | }) 47 | 48 | // FindInAST takes an AST node and walks the tree depth first 49 | // searching for a node of a specific type can be used to find first image, 50 | // link, paragraph...etc 51 | func FindInAST[t ast.Node](n ast.Node) (found t, ok bool) { 52 | if n == nil { 53 | return 54 | } 55 | 56 | ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 57 | if found, ok = n.(t); ok { 58 | return ast.WalkStop, nil 59 | } 60 | 61 | return ast.WalkContinue, nil 62 | }) 63 | 64 | return 65 | } 66 | 67 | // Extract all nodes of a specific type from the AST 68 | func FindAllInAST[t ast.Node](n ast.Node) (a []t) { 69 | if n == nil { 70 | return 71 | } 72 | 73 | ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 74 | if !entering { 75 | return ast.WalkContinue, nil 76 | } 77 | 78 | if casted, ok := n.(t); ok { 79 | a = append(a, casted) 80 | } 81 | return ast.WalkContinue, nil 82 | }) 83 | 84 | return 85 | } 86 | -------------------------------------------------------------------------------- /starred.md: -------------------------------------------------------------------------------- 1 | docs/Github 2 | tutorials/Creating a site 3 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "html/template" 8 | "io/fs" 9 | "log/slog" 10 | "os" 11 | "path" 12 | "strings" 13 | ) 14 | 15 | //go:embed templates 16 | var defaultTemplates embed.FS 17 | var templates *template.Template 18 | var templatesFSs []fs.FS 19 | 20 | // RegisterTemplate registers a filesystem that contains templates, specifying subDir as 21 | // the subdirectory name that contains the templates. templates are registered 22 | // such that the latest registered directory override older ones. template file 23 | // extensions are signified by '.html' extension and the file path can 24 | // be used as template name without this extension 25 | func RegisterTemplate(t fs.FS, subDir string) { 26 | ts, _ := fs.Sub(t, subDir) 27 | templatesFSs = append(templatesFSs, ts) 28 | } 29 | 30 | func compileTemplates() { 31 | const ext = ".html" 32 | 33 | // add default templates before everything else 34 | sub, _ := fs.Sub(defaultTemplates, "templates") 35 | templatesFSs = append([]fs.FS{sub}, templatesFSs...) 36 | // add theme directory after everything else to allow user to override any template 37 | if _, err := os.Stat("theme"); err == nil { 38 | templatesFSs = append(templatesFSs, os.DirFS("theme")) 39 | } 40 | 41 | templates = template.New("") 42 | for _, tfs := range templatesFSs { 43 | fs.WalkDir(tfs, ".", func(p string, d fs.DirEntry, err error) error { 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if strings.HasSuffix(p, ext) && d.Type().IsRegular() { 49 | ext := path.Ext(p) 50 | name := strings.TrimSuffix(p, ext) 51 | slog.Info("Template " + name) 52 | 53 | c, err := fs.ReadFile(tfs, p) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | template.Must(templates.New(name).Funcs(helpers).Parse(string(c))) 59 | } 60 | 61 | return nil 62 | }) 63 | } 64 | } 65 | 66 | // Partial executes a template by it's path name. it passes data to the 67 | // template. returning the output of the template. in case of an error it will 68 | // return the error string as the output 69 | func Partial(path string, data Locals) template.HTML { 70 | v := templates.Lookup(path) 71 | if v == nil { 72 | return template.HTML(fmt.Sprintf("template %s not found", path)) 73 | } 74 | 75 | if data == nil { 76 | data = Locals{} 77 | } 78 | 79 | data["config"] = Config 80 | 81 | w := bytes.NewBufferString("") 82 | 83 | if err := v.Execute(w, data); err != nil { 84 | return template.HTML("rendering error " + path + " " + err.Error()) 85 | } 86 | 87 | return template.HTML(w.String()) 88 | } 89 | -------------------------------------------------------------------------------- /templates/commands.html: -------------------------------------------------------------------------------- 1 | {{- with commands .page }} 2 | 25 | 26 | 60 | 61 | {{ end }} 62 | -------------------------------------------------------------------------------- /templates/emoji-favicon.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | {{- define "header" -}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{ $props := properties .page }} 11 | {{- with .page -}} 12 | {{- if ne .Name $.config.Index -}} 13 | {{ emoji . }} 14 | {{ with $props.title }} {{ .Value }} {{ else }} {{ .Name }} {{ end }} 15 | | 16 | {{ end -}} 17 | {{- end -}} 18 | {{.config.Sitename}} 19 | 20 | {{- with .page }} {{ widgets "head" . }} {{ end }} 21 | 22 | {{- with .page -}} 23 | {{- if ne .Name $.config.Index -}} 24 | {{ with emoji . }}{{ template "emoji-favicon" . }}{{ end }} 25 | {{- end -}} 26 | {{- end -}} 27 | 28 | 29 | {{- template "commands" . -}} 30 | {{- template "navbar" . -}} 31 | 32 |
    33 | 34 | {{- if ne .config.Index .page.Name -}} 35 |
    36 | {{- with .page -}} 37 | {{ with $props.title }} 38 | {{- emoji . }} 39 | {{ .Value }} 40 | {{ else }} 41 | {{ with dir .Name }}

    {{.}}

    {{ end }} 42 | {{- emoji . }} {{ base .Name }} 43 | {{ end }} 44 | {{- end -}} 45 |
    46 | {{- end -}} 47 | 48 |
    49 | {{- end -}} 50 | {{ define "footer" }} 51 |
    52 |
    53 | 54 | {{ scripts }} 55 | 56 | 57 | {{- end -}} 58 | -------------------------------------------------------------------------------- /templates/navbar.html: -------------------------------------------------------------------------------- 1 | 47 | -------------------------------------------------------------------------------- /templates/page.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 | 3 | {{- $props := properties .page -}} 4 | 5 | {{- with $props -}} 6 | 30 | {{- end -}} 31 | 32 |
    33 | 34 |
    35 | {{ if not .page.ModTime.IsZero }} 36 | 37 | 38 | Edited: {{ago .page.ModTime}} 39 | 40 | {{ end }} 41 |
    42 | 43 |
    44 |
    45 | 46 | {{- with $props -}} 47 | 48 | 49 | 50 | 51 | 52 | {{- end -}} 53 | 54 | {{- with quick_commands .page -}} 55 | 76 | {{- end -}} 77 | 78 |
    79 |
    80 | 81 |
    82 | 83 |
    84 | {{- with .page -}} 85 | {{- widgets "before_view" . -}} 86 | {{- .Render -}} 87 | {{- widgets "after_view" . -}} 88 | {{- end -}} 89 |
    90 | 91 | 96 | 97 | 98 | {{ template "footer" . }} 99 | -------------------------------------------------------------------------------- /templates/pages-grid.html: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /templates/pages.html: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /tutorials/Creating a site.md: -------------------------------------------------------------------------------- 1 | ![](/docs/public/website.png) 2 | 3 | #tutorial 4 | 5 | * Xlog doesn't require custom structure for your markdown files 6 | * Default main file name is `index.md` and can be overriden with `--index` flag 7 | 8 | # Create empty directory 9 | 10 | Create a new empty directory and `cd` into it or simple navigate to existing directory which has markdown files 11 | 12 | ```shell 13 | mkdir myblog 14 | cd myblog 15 | ``` 16 | 17 | # Run Xlog 18 | 19 | Assuming you already went through one of the Installation methods. `xlog` should be in your **PATH**. Simply executing it in current directory starts an HTTP server on port 3000 20 | 21 | ```shell 22 | xlog 23 | ``` 24 | 25 | # Running on a different port 26 | 27 | The previous command starts a server on port **3000** if you want to specify the port you can do so using `--bind` flag 28 | 29 | ```shell 30 | xlog --bind 127.0.0.1:4000 31 | ``` 32 | 33 | This will run the server on port **4000** instead of **3000** 34 | 35 | # Using a different index page 36 | 37 | Xlog assumes the main page is **index.md** if you're working in an existing github repository for example you may need to specify **README.md** as your index page as follows 38 | 39 | ```shell 40 | xlog --index README 41 | ``` 42 | 43 | Notice that specifying the index page doesn't need the extension `.md`. 44 | 45 | # Open your new site 46 | 47 | Now you can navigate to [http://localhost:3000](http://localhost:3000) in your browser to start browsing the markdown files. if it's a new directory your editor will open to write your first page. 48 | 49 | # Generating a static site 50 | 51 | You can generate HTML files from your markdown files using `--build` flag 52 | 53 | ```shell 54 | xlog --build . 55 | ``` 56 | 57 | Which will convert all of your markdown files to HTML files in the current directory. 58 | 59 | You can specify a destination for the HTML output. 60 | 61 | ```shell 62 | xlog --build /destination/directory/path 63 | ``` 64 | 65 | # Integration with Github pages 66 | 67 | If your markdown is hosted as Gituhub repository. You can use github workflows to download and execute xlog to generate HTML pages and host it with github pages. 68 | 69 | Tutorial can be found in Create your own digital garden on Github and Examples can be found here: 70 | - [Emad Elsaid Blog](https://github.com/emad-elsaid/emad-elsaid.github.io/blob/master/.github/workflows/xlog.yml) 71 | - [Xlog documentation](https://github.com/emad-elsaid/xlog/blob/master/.github/workflows/xlog.yml) 72 | -------------------------------------------------------------------------------- /tutorials/Custom installation.md: -------------------------------------------------------------------------------- 1 | ![](/docs/public/custom.png) 2 | 3 | #tutorial 4 | 5 | Xlog ships with a CLI that includes the core and all official extensions. There are cases where you need custom set of extensions: 6 | 7 | * Maybe you need only the core features without any extension 8 | * Maybe there is an extension that you don't need or want or misbehaving 9 | * Maybe you developed a set of extensions and you want to include them in your installations 10 | 11 | Here is how you can build your own custom xlog with features you select. 12 | 13 | # Creating a Go module 14 | 15 | Create a directory for your custom installation and initialize a go module in it. 16 | 17 | ```shell 18 | mkdir custom_xlog 19 | cd custom_xlog 20 | go mod init github.com/yourusername/custom_xlog 21 | ``` 22 | 23 | # Main file 24 | 25 | Then create a file `xlog.go` for example with the following content 26 | 27 | ```go 28 | package main 29 | 30 | import ( 31 | // Core 32 | "github.com/emad-elsaid/xlog" 33 | 34 | // All official extensions 35 | _ "github.com/emad-elsaid/xlog/extensions/all" 36 | ) 37 | 38 | func main() { 39 | xlog.Start() 40 | } 41 | ``` 42 | 43 | # Selecting extensions 44 | 45 | The previous file is what xlog ships in `cmd/xlog/xlog.go` if you missed up at any point feel free to go back to it and copy it from there. 46 | 47 | If you want to select specific extensions you can replace `extensions/all` line with a list of extensions that you want. 48 | 49 | All extensions are imported to [`extensions/all/all.go`](https://github.com/emad-elsaid/xlog/blob/master/extensions/all/all.go). feel free to copy any of them as needed. 50 | 51 | You can also import any extensions that you developed at this point. 52 | 53 | # Running your custom xlog 54 | 55 | Now use Go to run your custom installation 56 | 57 | ```shell 58 | go get github.com/emad-elsaid/xlog 59 | go run xlog.go 60 | ``` 61 | 62 | -------------------------------------------------------------------------------- /tutorials/Generate static website.md: -------------------------------------------------------------------------------- 1 | ![](/docs/public/static.png) 2 | 3 | #tutorial 4 | 5 | Xlog CLI allow for generating static website from source directory. this is how this website is generated. 6 | 7 | To generate a static website using Xlog use the `--build` flag with a path as destination for example: 8 | 9 | ```shell 10 | xlog --build /path/to/output 11 | ``` 12 | 13 | Xlog will build all markdown files to HTML and extract all static files from inside the binary executable file to that destination directory. Then it will terminate. 14 | 15 | Building process creates a xlog server instance and request all pages and save it to desk. That allow xlog extensions to define a new handler that renders a page. the page will work in both usecases: local server, static site generation. extensions has to also register the path for build using [`RegisteBuildPage`](https://pkg.go.dev/github.com/emad-elsaid/xlog#RegisterBuildPage) function 16 | 17 | While building static site xlog turns on **READONLY** mode. 18 | 19 | -------------------------------------------------------------------------------- /widgets.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "html/template" 5 | "iter" 6 | "sort" 7 | ) 8 | 9 | type ( 10 | // WidgetSpace used to represent a widgets spaces. it's used to register 11 | // widgets to be injected in the view or edit pages 12 | WidgetSpace string 13 | // WidgetFunc a function that takes the current page and returns the widget. 14 | // This can be used by extensions to define new widgets to be rendered in 15 | // view or edit pages. the extension should define this func type and 16 | // register it to be rendered in a specific widgetSpace such as before or 17 | // after the page. 18 | WidgetFunc func(Page) template.HTML 19 | ) 20 | 21 | // List of widgets spaces that extensions can use to register a WidgetFunc to 22 | // inject content into. 23 | var ( 24 | WidgetAfterView WidgetSpace = "after_view" // widgets rendered after the content of the view page 25 | WidgetBeforeView WidgetSpace = "before_view" // widgets rendered before the content of the view page 26 | WidgetHead WidgetSpace = "head" // widgets rendered in page tag 27 | ) 28 | 29 | // A map to keep track of list of widget functions registered in each widget space 30 | var widgets = map[WidgetSpace]*priorityList[WidgetFunc]{} 31 | 32 | // RegisterWidget Register a function to a widget space. functions registered 33 | // will be executed in order of priority lower to higher when rendering view or 34 | // edit page. the return values of these widgetfuncs will pass down to the 35 | // template and injected in reserved places. 36 | func RegisterWidget(s WidgetSpace, priority float32, f WidgetFunc) { 37 | pl, ok := widgets[s] 38 | if !ok { 39 | pl = new(priorityList[WidgetFunc]) 40 | widgets[s] = pl 41 | } 42 | 43 | pl.Add(f, priority) 44 | } 45 | 46 | // This is used by view and edit routes to render all widgetfuncs registered for 47 | // specific widget space. 48 | func RenderWidget(s WidgetSpace, p Page) (o template.HTML) { 49 | w, ok := widgets[s] 50 | if !ok { 51 | return 52 | } 53 | 54 | for f := range w.All() { 55 | o += f(p) 56 | } 57 | return 58 | } 59 | 60 | type priorityItem[T any] struct { 61 | Item T 62 | Priority float32 63 | } 64 | 65 | type priorityList[T any] struct { 66 | items []priorityItem[T] 67 | } 68 | 69 | func (pl *priorityList[T]) Add(item T, priority float32) { 70 | pl.items = append(pl.items, priorityItem[T]{Item: item, Priority: priority}) 71 | pl.sortByPriority() 72 | } 73 | 74 | func (pl *priorityList[T]) sortByPriority() { 75 | sort.Slice(pl.items, func(i, j int) bool { 76 | return pl.items[i].Priority < pl.items[j].Priority 77 | }) 78 | } 79 | 80 | // An iterator over all items 81 | func (pl *priorityList[T]) All() iter.Seq[T] { 82 | return func(yield func(T) bool) { 83 | for _, v := range pl.items { 84 | if !yield(v.Item) { 85 | return 86 | } 87 | } 88 | } 89 | } 90 | --------------------------------------------------------------------------------