├── .github └── workflows │ ├── codeql-analysis.yml │ ├── deno-deploy.yml │ └── test.yml ├── .gitignore ├── .ter └── config.json ├── .vscode └── settings.json ├── attributes.ts ├── components ├── Article.tsx ├── Body.tsx ├── Footer.tsx ├── Header.tsx ├── IndexGrid.tsx ├── IndexList.tsx ├── IndexLog.tsx └── icons.tsx ├── config.ts ├── constants.ts ├── deno.json ├── deno.lock ├── deps ├── deepmerge.ts ├── feed.ts ├── hljs.ts ├── marked.ts ├── preact.ts ├── slug.ts ├── std.ts ├── twind.ts └── ufo.ts ├── docs ├── example-sites.md ├── gfm.md ├── goals.md ├── img │ ├── img-large.jpg │ └── img-small.jpg ├── index.md ├── todo.md ├── usage │ ├── cli-usage.md │ ├── configuration.md │ ├── content.md │ ├── deploy.md │ └── index.md └── zettelkasten.md ├── entries.ts ├── entries_test.ts ├── feed.ts ├── license ├── main.ts ├── markdown.ts ├── markdown_test.ts ├── pages.ts ├── pages_test.ts ├── readme.md ├── render.tsx ├── serve.ts ├── test └── entries │ ├── .should-skip.md │ ├── 2022-12-12-entry-2.md │ ├── _should-skip.md │ ├── dir │ └── child-entry-1.md │ ├── entry-1.md │ ├── img-small.jpg │ └── not-markdown.txt ├── twind.config.ts └── types.d.ts /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: "15 0 * * 5" 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: ["typescript"] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v2 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | -------------------------------------------------------------------------------- /.github/workflows/deno-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Deno Deploy 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | deploy: 11 | name: Deploy 12 | runs-on: ubuntu-latest 13 | permissions: 14 | id-token: write 15 | contents: read 16 | 17 | steps: 18 | - name: Clone repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Deno 22 | uses: denoland/setup-deno@v1 23 | with: 24 | deno-version: v1.x 25 | 26 | - name: Build site 27 | run: deno task build 28 | 29 | - name: Deploy to Deno Deploy 30 | uses: denoland/deployctl@v1 31 | with: 32 | project: ter 33 | entrypoint: https://deno.land/std/http/file_server.ts 34 | root: _site 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Deno tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | run-tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: denoland/setup-deno@v1 15 | with: 16 | deno-version: v1.x 17 | - name: Run tests 18 | run: deno task test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | test-10000 3 | cov_profile 4 | node_modules 5 | -------------------------------------------------------------------------------- /.ter/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Ter", 3 | "description": "A tiny wiki-style site builder with Zettelkasten flavor", 4 | "url": "https://ter.kkga.me/", 5 | "authorName": "Gadzhi Kharkharov", 6 | "authorEmail": "x@kkga.me", 7 | "authorUrl": "https://kkga.me/", 8 | "head": "", 9 | "codeHighlight": true 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.codeLens.test": true, 5 | "editor.defaultFormatter": "denoland.vscode-deno", 6 | "deno.unstable": true, 7 | "[github-actions-workflow]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /attributes.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from "./types.d.ts"; 2 | 3 | export const hasKey = (data: JSONValue, keys: string[]): boolean => { 4 | if (data) { 5 | for (const key of Object.keys(data)) { 6 | if (keys.includes(key) && data[key] === true) { 7 | return true; 8 | } 9 | } 10 | } 11 | return false; 12 | }; 13 | 14 | export const getVal = (data: JSONValue, key: string): unknown | undefined => 15 | data[key] ?? undefined; 16 | 17 | export const getBool = (data: JSONValue, key: string): boolean | undefined => 18 | data[key] !== undefined && typeof data[key] === "boolean" 19 | ? (data[key] as boolean) 20 | : undefined; 21 | 22 | export const getTitle = (data: JSONValue): string | undefined => 23 | typeof data.title === "string" ? data.title : undefined; 24 | 25 | export const getDescription = (data: JSONValue): string | undefined => 26 | typeof data.description === "string" ? data.description : undefined; 27 | 28 | export const getDate = (data: JSONValue): Date | undefined => 29 | data.date instanceof Date ? data.date : undefined; 30 | 31 | export const getDateUpdated = (data: JSONValue): Date | undefined => 32 | data.dateUpdated instanceof Date ? data.dateUpdated : undefined; 33 | 34 | export const getTags = (data: JSONValue): string[] | undefined => 35 | Array.isArray(data.tags) ? data.tags.map((t) => t.toString()) : undefined; 36 | -------------------------------------------------------------------------------- /components/Article.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource npm:preact */ 2 | 3 | import { cx } from "../deps/twind.ts"; 4 | import { Heading, Page } from "../types.d.ts"; 5 | 6 | const Toc = ({ headings }: { headings: Heading[] }) => ( 7 |
{description}
} 68 | 69 | {(datePublished || dateUpdated || tags) && ( 70 | 99 | )} 100 |130 | This is a paragraph inside details. Bacon ipsum dolor sit amet t-bone doner shank 131 | drumstick, pork belly porchetta chuck sausage brisket ham hock rump pig. Chuck 132 | kielbasa leberkas, pork bresaola ham hock filet mignon cow shoulder short ribs 133 | biltong. 134 |
135 |${html}`; 143 | }; 144 | } 145 | 146 | marked.use({ renderer }); 147 | 148 | const html = marked.parser(tokens); 149 | 150 | return { html, links: Array.from(internalLinks), headings }; 151 | }; 152 | -------------------------------------------------------------------------------- /markdown_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std@0.202.0/assert/mod.ts"; 2 | import { parseMarkdown } from "./markdown.ts"; 3 | 4 | Deno.test("parseMarkdown should return html, links, and headings", () => { 5 | const markdown = `## Hello World\nThis is a [link](some-file.md).`; 6 | const expected = `
This is a link.
\n`; 7 | 8 | const { html, links, headings } = parseMarkdown({ 9 | text: markdown, 10 | currentPath: "/some-page", 11 | baseUrl: new URL("https://example.com/"), 12 | }); 13 | 14 | assertEquals(html, expected); 15 | assertEquals(links, [new URL("https://example.com/some-file")]); 16 | assertEquals(headings, [ 17 | { text: "Hello World", level: 2, slug: "hello-world" }, 18 | ]); 19 | }); 20 | 21 | Deno.test("Markdown with external links", () => { 22 | const markdown = `This is a [link](https://example.com).`; 23 | const expected = `This is a link.
\n`; 24 | 25 | const { html } = parseMarkdown({ 26 | text: markdown, 27 | currentPath: ".", 28 | baseUrl: new URL("https://example.com/"), 29 | }); 30 | 31 | assertEquals(html, expected); 32 | }); 33 | 34 | Deno.test("Markdown with internal links", () => { 35 | const markdown = `This is a [link](/notes).`; 36 | const expected = `This is a link.
\n`; 37 | 38 | const { html } = parseMarkdown({ 39 | text: markdown, 40 | currentPath: ".", 41 | baseUrl: new URL("https://example.com/"), 42 | }); 43 | 44 | assertEquals(html, expected); 45 | }); 46 | 47 | Deno.test("Markdown with a relative link on an index page", () => { 48 | const markdown = `This is a [link](some-page).`; 49 | const expected = `This is a link.
\n`; 50 | 51 | const { html } = parseMarkdown({ 52 | text: markdown, 53 | currentPath: "/pages/notes", 54 | baseUrl: new URL("https://example.com/"), 55 | isDirIndex: true, 56 | }); 57 | 58 | assertEquals(html, expected); 59 | }); 60 | 61 | Deno.test("Markdown with an absolute link on an index page", () => { 62 | const markdown = `This is a [link](/top-level-page).`; 63 | const expected = `This is a link.
\n`; 64 | 65 | const { html } = parseMarkdown({ 66 | text: markdown, 67 | currentPath: "/pages/notes", 68 | baseUrl: new URL("https://example.com/"), 69 | isDirIndex: true, 70 | }); 71 | 72 | assertEquals(html, expected); 73 | }); 74 | 75 | Deno.test("Markdown with a relative link on a non-index page", () => { 76 | const markdown = `This is a [link](some-page).`; 77 | const expected = `This is a link.
\n`; 78 | 79 | const { html } = parseMarkdown({ 80 | text: markdown, 81 | currentPath: "/pages/notes/another-page", 82 | baseUrl: new URL("https://example.com/"), 83 | isDirIndex: false, 84 | }); 85 | 86 | assertEquals(html, expected); 87 | }); 88 | 89 | Deno.test("Markdown with a relative link to a parent directory", () => { 90 | const markdown = `This is a [link](../some-page.md).`; 91 | const expected = `This is a link.
\n`; 92 | 93 | const { html } = parseMarkdown({ 94 | text: markdown, 95 | currentPath: "/pages/notes/another-page", 96 | baseUrl: new URL("https://example.com/"), 97 | }); 98 | 99 | assertEquals(html, expected); 100 | }); 101 | 102 | Deno.test( 103 | "Markdown with a relative link to a parent directory on an index page", 104 | () => { 105 | const markdown = `This is a [link](../some-page.md).`; 106 | const expected = `This is a link.
\n`; 107 | 108 | const { html } = parseMarkdown({ 109 | text: markdown, 110 | currentPath: "/pages/notes", 111 | baseUrl: new URL("https://example.com/"), 112 | isDirIndex: true, 113 | }); 114 | 115 | assertEquals(html, expected); 116 | } 117 | ); 118 | 119 | Deno.test( 120 | "Links to non-markdown files should not have the extension removed", 121 | () => { 122 | const markdown = `This is a [link](some-file.pdf).`; 123 | const expected = `This is a link.
\n`; 124 | 125 | const { html } = parseMarkdown({ 126 | text: markdown, 127 | currentPath: "/some-page", 128 | baseUrl: new URL("https://example.com/"), 129 | }); 130 | 131 | assertEquals(html, expected); 132 | } 133 | ); 134 | 135 | Deno.test( 136 | "Relative links to markdown files with date prefixes should have the prefix removed", 137 | () => { 138 | const markdown = `This is a [link](./2020-01-01-some-file.md).`; 139 | const expected = `This is a link.
\n`; 140 | 141 | const { html } = parseMarkdown({ 142 | text: markdown, 143 | currentPath: "/some-page", 144 | baseUrl: new URL("https://example.com/"), 145 | }); 146 | 147 | assertEquals(html, expected); 148 | } 149 | ); 150 | 151 | Deno.test( 152 | "Absolute links to markdown files with date prefixes should have the prefix removed", 153 | () => { 154 | const markdown = `This is a [link](/2020-01-01-some-file.md).`; 155 | const expected = `This is a link.
\n`; 156 | 157 | const { html } = parseMarkdown({ 158 | text: markdown, 159 | currentPath: "/some-page", 160 | baseUrl: new URL("https://example.com/"), 161 | }); 162 | 163 | assertEquals(html, expected); 164 | } 165 | ); 166 | -------------------------------------------------------------------------------- /pages.ts: -------------------------------------------------------------------------------- 1 | import { fm, path, fs } from "./deps/std.ts"; 2 | 3 | import { slug as slugify } from "./deps/slug.ts"; 4 | 5 | import * as attributes from "./attributes.ts"; 6 | import { parseMarkdown } from "./markdown.ts"; 7 | 8 | import type { 9 | Crumb, 10 | Heading, 11 | JSONValue, 12 | Page, 13 | PageData, 14 | UserConfig, 15 | } from "./types.d.ts"; 16 | 17 | interface GeneratePageOpts { 18 | entry: fs.WalkEntry; 19 | inputPath: string; 20 | userConfig: UserConfig; 21 | ignoreKeys: string[]; 22 | } 23 | 24 | const decoder = new TextDecoder("utf-8"); 25 | 26 | function generateCrumbs(page: Page, rootCrumb?: string): Crumb[] { 27 | const chunks = page.url.pathname.split("/").filter((ch) => !!ch); 28 | 29 | const crumbs: Crumb[] = chunks.map((chunk, i) => { 30 | const url = path.join("/", ...chunks.slice(0, i + 1)); 31 | return { 32 | slug: chunk, 33 | url, 34 | current: url === page.url.pathname, 35 | }; 36 | }); 37 | 38 | crumbs.unshift({ 39 | slug: rootCrumb ?? "index", 40 | url: "/", 41 | current: page.url.pathname === "/", 42 | }); 43 | 44 | return crumbs; 45 | } 46 | 47 | function getTitleFromHeadings(headings: Array