diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1790665..6205781 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,3 @@ -custom: https://afdian.com/a/leoly +custom: + - https://ko-fi.com/M4M212WUCI + - https://afdian.com/a/leoly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e5a563..63c5308 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ name: CI -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize] jobs: build: @@ -25,6 +29,8 @@ jobs: version: '0.4.27' - name: Package Test run: pnpm run package + - name: Build playground + run: pnpm run pg-build - name: Upload APKGs uses: actions/upload-artifact@v4.4.3 diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml new file mode 100644 index 0000000..b039b3d --- /dev/null +++ b/.github/workflows/playground.yml @@ -0,0 +1,44 @@ +name: Playground + +on: + push: + branches: [main] + +jobs: + build: + if: | + !startsWith(github.event.head_commit.message , 'chore: release') + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + + steps: + - uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + - name: Use Node.js 23 + uses: actions/setup-node@v4 + with: + node-version: '23.6' + cache: 'pnpm' + - run: pnpm install + - run: pnpm test + - run: pnpm run build + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: '0.4.27' + - name: Package Test + run: pnpm run package + - name: Build playground + run: pnpm run pg-build + - name: Publish + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: 'anki-template' + directory: 'playground/dist' + gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9561ad3..9f8e526 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -24,7 +24,7 @@ jobs: - name: Install Playwright Browsers run: pnpm exec playwright install --with-deps - name: Run Playwright tests - run: pnpm exec playwright test + run: pnpm e2e-test - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/README.md b/README.md index 5474f3f..71aeee3 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,23 @@ # ikkz Templates -For the directly usable version, please download it from the [release](https://github.com/ikkz/anki-template/releases). - -> [!TIP] -> Each template has multiple variants available for download, with the filename format being `{template}.{locale}.{field}.apkg` -> -> Be sure to read the specific instructions and tips for each template below before use - -``` -template: -- mcq : Multiple choice question (6 options) -- mcq_10: Multiple choice question (10 options) -- tf : True or false -- basic : Basic Q&A -- match : Drag and drop interactive matching - -locale: -- zh: 中文 -- en: English -- ja: Japanese - -field: -- native : The native Anki field -- markdown: With markdown support, but larger size -``` +For the directly usable version, please download it from the [here](https://template.ikkz.fun). For suggestions and feedback, please submit them [here](https://github.com/ikkz/anki-template/issues). ## Templates +| Template | Description | Links | +| -------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| mcq | Multiple choice question (6 options) | [Preview](https://template.ikkz.fun/?template=mcq.en.native) [Docs](https://template.ikkz.fun/docs/mcq) | +| mcq_10 | Multiple choice question (10 options) | [Preview](https://template.ikkz.fun/?template=mcq_10.en.native) [Docs](https://template.ikkz.fun/docs/mcq_10) | +| match | Drag and drop interactive matching | [Preview](https://template.ikkz.fun/?template=match.en.native) [Docs](https://template.ikkz.fun/docs/match) | +| tf | True or false | [Preview](https://template.ikkz.fun/?template=tf.en.native) [Docs](https://template.ikkz.fun/docs/tf) | +| basic | Basic Q&A | [Preview](https://template.ikkz.fun/?template=basic.en.native) [Docs](https://template.ikkz.fun/docs/basic) | + All of the templates have the following common features: -- Markdown support: see [here](/docs/markdown.md) for details +- Markdown support: see [here](https://template.ikkz.fun/docs/markdown) for details - After selecting text, you can directly click to ask gpt, search or translate the corresponding text, and customization is also supported - Support larger question text. - Support dark mode and light mode. - Countdown: Give you motivation to learn. - -### Multiple Choice - -- Support hiding options to avoid potential answer hints -- Support single choice and multiple choice. -- Scrambled question options are restored after showing the answer. -- Obvious answer markers. - -#### Fields - -Note: When all options are empty, the template will behave as a basic Q&A template - -| Field name | Description | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | -| optionA...F | This is the content of the question options. Options that are not filled in will not be displayed, and various formats are also supported. | -| answer | This is the answer to the question. For multiple-choice questions, please write the uppercase letter of the correct answer, for example, A. For multiple-choice questions, write all the correct answer letters, such as ABC. | -| note | You can fill in detailed explanations, notes, etc., here. | -| noteA...F | You can fill in detailed explanations, notes for every option | - -### Match - -Drag and drop interactive matching question template. - -> [!TIP] -> It is best to disable all swipe gesture controls in Anki's review settings. - -#### Fields - -Notes for `items` - -- Each line starts with a category, followed by two colons separating it from the items under that category -- Each item is separated by two commas - -An example: - -``` -Mammals::Tiger,,Elephant -Birds::Penguin,,Parrot -Reptiles::Cobra,,Crocodile -``` - -| Field name | Description | -| ---------- | ------------------------------------------------------------------------------------------------------------- | -| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | -| items | The category and items | -| note | You can fill in detailed explanations, notes, etc., here. | - -### True or False - -#### Fields - -Notes for `items` - -- All sub-questions should meet the format constriant -- Each sub-question must begin with a line "T===" or "F===", indicating whether the sub-question is true or false -- Pay special attention to ensuring "T/F" is followed by three or more equal signs - -| Field name | Description | -| ---------- | ------------------------------------------------------------------------------------------------------------- | -| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | -| items | The sub-questions | -| note | You can fill in detailed explanations, notes, etc., here. | - -### Basic - -A Simple Q&A Template - -#### Fields - -| Field name | Description | -| ---------- | ------------------------------------------------------------------------------------------------------------- | -| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | -| answer | This is the answer to the question, and various formats are also supported | -| note | You can fill in detailed explanations, notes, etc., here. | - -## Screenshots - -image - -图片 - -图片 - -图片 diff --git a/build/entries.ts b/build/entries.ts index 8a279be..9960e05 100644 --- a/build/entries.ts +++ b/build/entries.ts @@ -1,4 +1,4 @@ -import type { BuildConfig } from './config'; +import type { BuildConfig } from './config.ts'; export interface Note { config: Partial; @@ -7,6 +7,7 @@ export interface Note { interface Entry { fields: F; + desc: string; notes: Note[]; } @@ -20,6 +21,7 @@ const mdQuestion = "## Markdown Basic Syntax

I just love **bold text**. Italicized text is the _cat's meow_. At the command prompt, type `nano`.

My favorite markdown editor is [ByteMD](https://github.com/bytedance/bytemd).

1. First item
2. Second item
3. Third item

> Dorothy followed her through many of the beautiful rooms in her castle.

```js
import gfm from '@bytemd/plugin-gfm'
import { Editor, Viewer } from 'bytemd'

const plugins = [
  gfm(),
  // Add more plugins here
]

const editor = new Editor({
  target: document.body, // DOM to render
  props: {
    value: '',
    plugins,
  },
})

editor.on('change', (e) => {
  editor.$set({ value: e.detail.value })
})
```

## GFM Extended Syntax

Automatic URL Linking: https://github.com/bytedance/bytemd

~~The world is flat.~~ We now know that the world is round.

- [x] Write the press release
- [ ] Update the website
- [ ] Contact the media

| Syntax    | Description |
| --------- | ----------- |
| Header    | Title       |
| Paragraph | Text        |

## Math Equation

Inline math equation: $a+b$

$$
\\displaystyle \\left( \\sum_{k=1}^n a_k b_k \\right)^2 \\leq \\left( \\sum_{k=1}^n a_k^2 \\right) \\left( \\sum_{k=1}^n b_k^2 \\right)
$$

## Mermaid Diagrams

```mermaid
mindmap
  root((mindmap))
    Origins
      Long history
      ::icon(fa fa-book)
      Popularisation
        British popular psychology author Tony Buzan
    Research
      On effectiveness<br/>and features
      On Automatic creation
        Uses
            Creative techniques
            Strategic planning
            Argument mapping
    Tools
      Pen and paper
      Mermaid

```"; const mcq = defineEntry({ + desc: 'Multiple choice question (6 options)', fields: [ 'question', 'optionA', @@ -74,6 +76,7 @@ const mcq = defineEntry({ }); const mcq_10 = defineEntry({ + desc: 'Multiple choice question (10 options)', fields: [ 'question', 'optionA', @@ -107,6 +110,7 @@ const entries = { mcq, mcq_10, basic: defineEntry({ + desc: 'Basic Q&A', fields: ['question', 'answer', 'note', 'Tags'], notes: [ { @@ -133,6 +137,7 @@ const entries = { ], }), tf: defineEntry({ + desc: 'True or false', fields: ['question', 'items', 'note', 'Tags'], notes: [ { @@ -161,6 +166,7 @@ const entries = { ], }), match: defineEntry({ + desc: 'Drag and drop interactive matching', fields: ['question', 'items', 'note', 'Tags'], notes: [ { diff --git a/build/plugins/generate-template.ts b/build/plugins/generate-template.ts index 09ab08c..0caaa75 100644 --- a/build/plugins/generate-template.ts +++ b/build/plugins/generate-template.ts @@ -1,7 +1,6 @@ import type { BuildConfig } from '../config.ts'; import { entries, type Note } from '../entries.ts'; import { findMatchNote } from '../utils.ts'; -import { extname } from 'node:path'; import * as R from 'remeda'; import type { Plugin } from 'rollup'; @@ -26,7 +25,7 @@ export default (config: BuildConfig) => name: 'generate-template', generateBundle(_, bundle) { Object.keys(bundle) - .filter((fileName) => extname(fileName) !== '.html') + .filter((fileName) => fileName.split('.').pop() !== 'html') .forEach((fileName) => { delete bundle[fileName]; }); diff --git a/build/rollup.ts b/build/rollup.ts index 31b460c..3711a56 100644 --- a/build/rollup.ts +++ b/build/rollup.ts @@ -2,7 +2,7 @@ import type { BuildConfig } from './config.ts'; import { entries } from './entries.ts'; import devServer from './plugins/dev-server/index.ts'; import generateTemplate from './plugins/generate-template.ts'; -import { readJson, ensureValue, findMatchNote } from './utils.ts'; +import { ensureValue, findMatchNote } from './utils.ts'; import alias from '@rollup/plugin-alias'; import commonjs from '@rollup/plugin-commonjs'; import html from '@rollup/plugin-html'; @@ -25,10 +25,13 @@ import type { import nodePolyfills from 'rollup-plugin-polyfill-node'; import postcss from 'rollup-plugin-postcss'; import { swc, minify } from 'rollup-plugin-swc3'; -// import { visualizer } from 'rollup-plugin-visualizer'; import tailwindcss from 'tailwindcss'; -const packageJson = await readJson('./package.json'); +const packageJson = JSON.parse( + await fs.readFile(path.resolve(import.meta.dirname, '../package.json'), { + encoding: 'utf8', + }), +); export async function rollupOptions( config: BuildConfig, diff --git a/build/utils.ts b/build/utils.ts index 6cb5fb9..096d1e5 100644 --- a/build/utils.ts +++ b/build/utils.ts @@ -1,19 +1,11 @@ -import type { BuildConfig } from './config'; +import type { BuildConfig } from './config.ts'; import { entries } from './entries.ts'; -import fs from 'node:fs/promises'; +import { template } from 'lodash-es'; export function ensureValue(value: T): T extends () => infer R ? R : T { return typeof value === 'function' ? value() : value; } -export async function readJson(path: string) { - return JSON.parse( - await fs.readFile(path, { - encoding: 'utf-8', - }), - ); -} - export function configMatch( pattern: Partial, config: BuildConfig, @@ -29,3 +21,9 @@ export function findMatchNote(config: BuildConfig) { configMatch(noteConfig, config), ); } + +export function renderTemplate(html: string, data: object) { + return template(html, { + interpolate: /{{([\s\S]+?)}}/g, + })(data); +} diff --git a/docs/basic.md b/docs/basic.md new file mode 100644 index 0000000..dd331cf --- /dev/null +++ b/docs/basic.md @@ -0,0 +1,11 @@ +# Basic + +A Simple Q&A Template + +## Fields + +| Field name | Description | +| ---------- | ------------------------------------------------------------------------------------------------------------- | +| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | +| answer | This is the answer to the question, and various formats are also supported | +| note | You can fill in detailed explanations, notes, etc., here. | diff --git a/docs/match.md b/docs/match.md new file mode 100644 index 0000000..2bb1ec5 --- /dev/null +++ b/docs/match.md @@ -0,0 +1,27 @@ +# Match + +Drag and drop interactive matching question template. + +> [!TIP] +> It is best to disable all swipe gesture controls in Anki's review settings. + +## Fields + +Notes for `items` + +- Each line starts with a category, followed by two colons separating it from the items under that category +- Each item is separated by two commas + +An example: + +``` +Mammals::Tiger,,Elephant +Birds::Penguin,,Parrot +Reptiles::Cobra,,Crocodile +``` + +| Field name | Description | +| ---------- | ------------------------------------------------------------------------------------------------------------- | +| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | +| items | The category and items | +| note | You can fill in detailed explanations, notes, etc., here. | diff --git a/docs/mcq.md b/docs/mcq.md new file mode 100644 index 0000000..b1298ad --- /dev/null +++ b/docs/mcq.md @@ -0,0 +1,18 @@ +# Multiple Choice + +- Support hiding options to avoid potential answer hints +- Support single choice and multiple choice. +- Scrambled question options are restored after showing the answer. +- Obvious answer markers. + +## Fields + +Note: When all options are empty, the template will behave as a basic Q&A template + +| Field name | Description | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | +| optionA...F | This is the content of the question options. Options that are not filled in will not be displayed, and various formats are also supported. | +| answer | This is the answer to the question. For multiple-choice questions, please write the uppercase letter of the correct answer, for example, A. For multiple-choice questions, write all the correct answer letters, such as ABC. | +| note | You can fill in detailed explanations, notes, etc., here. | +| noteA...F | You can fill in detailed explanations, notes for every option | diff --git a/docs/tf.md b/docs/tf.md new file mode 100644 index 0000000..9b27d3b --- /dev/null +++ b/docs/tf.md @@ -0,0 +1,15 @@ +# True or False + +## Fields + +Notes for `items` + +- All sub-questions should meet the format constriant +- Each sub-question must begin with a line "T===" or "F===", indicating whether the sub-question is true or false +- Pay special attention to ensuring "T/F" is followed by three or more equal signs + +| Field name | Description | +| ---------- | ------------------------------------------------------------------------------------------------------------- | +| question | This is the stem of the question. It supports various content formats in Anki, including bold, formulas, etc. | +| items | The sub-questions | +| note | You can fill in detailed explanations, notes, etc., here. | diff --git a/e2e/anki.ts b/e2e/anki.ts index bf827e3..ee9b4bd 100644 --- a/e2e/anki.ts +++ b/e2e/anki.ts @@ -2,9 +2,9 @@ import { type BuildJson, BUILTIN_FIELDS, } from '../build/plugins/generate-template'; +import { renderTemplate } from '../build/utils'; import { readTemplate } from './utils'; import { type Page } from '@playwright/test'; -import { template } from 'lodash-es'; declare const e2eAnki: { clean(): void; @@ -67,9 +67,3 @@ export class Anki { }); } } - -function renderTemplate(html: string, data: object) { - return template(html, { - interpolate: /{{([\s\S]+?)}}/g, - })(data); -} diff --git a/package.json b/package.json index 8f2e4c8..ed51711 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,13 @@ "lint": "pnpm eslint .", "format": "pnpm eslint --fix .", "package": "uv run --frozen build/package.py", - "typecheck": "tsc", - "test-e2e": "pnpm exec playwright test", - "serve-e2e": "pnpm exec serve -p 3001 e2e", - "test": "vitest" + "typecheck": "tsc --build --noEmit", + "e2e-test": "pnpm exec playwright test", + "e2e-serve": "pnpm exec serve -p 3001 e2e", + "test": "vitest", + "pg-build": "vite build --config playground/vite.config.ts", + "pg-dev": "vite --config playground/vite.config.ts", + "pg-preview": "vite preview --config playground/vite.config.ts" }, "author": "", "license": "ISC", @@ -44,7 +47,9 @@ "@types/lodash-es": "^4.17.12", "@types/node": "^22.5.3", "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.5", "@types/webfontloader": "^1.6.38", + "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "cssnano": "^7.0.5", "eslint": "^9.9.1", @@ -55,7 +60,7 @@ "jsdom": "^25.0.1", "koa": "^2.15.3", "pnpm": "^9.9.0", - "postcss": "^8.4.44", + "postcss": "^8.5.1", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.6", "rollup": "^4.21.2", @@ -68,9 +73,14 @@ "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.3", "typescript-eslint": "^8.4.0", - "vitest": "^2.1.8" + "vite": "^6.1.0", + "vite-plugin-markdown": "^2.2.0", + "vite-plugin-pages": "^0.32.4", + "vitest": "^2.1.8", + "wrangler": "^3.107.3" }, "dependencies": { + "@ant-design/icons": "^5.6.0", "@bytemd/plugin-breaks": "^1.21.0", "@bytemd/plugin-gfm": "^1.21.0", "@bytemd/plugin-highlight": "^1.21.0", @@ -81,6 +91,7 @@ "@formkit/auto-animate": "^0.8.2", "ahooks": "^3.8.1", "anki-storage": "^1.0.2", + "antd": "^5.23.4", "bytemd": "^1.21.0", "clsx": "^2.1.1", "jotai": "^2.11.0", @@ -89,7 +100,12 @@ "lucide-react": "^0.468.0", "mermaid": "^11.4.1", "preact": "^10.23.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-error-boundary": "^4.1.1", + "react-quill": "^2.0.0", + "react-router": "^7.1.5", + "react-router-dom": "^7.1.5", "react-use": "^17.5.1", "remeda": "^2.12.0" } diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..aac21b7 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,28 @@ + + + + + + ikkz Template Playground + + + + + +
+ + + diff --git a/playground/public/builds b/playground/public/builds new file mode 120000 index 0000000..7724b92 --- /dev/null +++ b/playground/public/builds @@ -0,0 +1 @@ +../../dist \ No newline at end of file diff --git a/playground/public/releases b/playground/public/releases new file mode 120000 index 0000000..8caa4cf --- /dev/null +++ b/playground/public/releases @@ -0,0 +1 @@ +../../release \ No newline at end of file diff --git a/playground/src/components/header.tsx b/playground/src/components/header.tsx new file mode 100644 index 0000000..dabb2b6 --- /dev/null +++ b/playground/src/components/header.tsx @@ -0,0 +1,53 @@ +import { version } from '../../../package.json'; +import { GithubOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import { FC } from 'react'; +import { useLocation } from 'react-router'; + +export const Header: FC = () => { + const location = useLocation(); + return ( + <> +

+ ikkz Template@{version} +

+
+ Docs + + + {location.pathname === '/' ? null : ( + <> + Go to + + + )} +
+ + ); +}; diff --git a/playground/src/components/previewer.tsx b/playground/src/components/previewer.tsx new file mode 100644 index 0000000..1572a9b --- /dev/null +++ b/playground/src/components/previewer.tsx @@ -0,0 +1,176 @@ +import { + BuildJson, + BUILTIN_FIELDS, +} from '../../../build/plugins/generate-template'; +import { renderTemplate } from '../../../build/utils'; +import hostPage from '../../../e2e/index.html?raw'; +import { useRequest } from 'ahooks'; +import { Button, Spin } from 'antd'; +import { FC, useEffect, useRef, useState } from 'react'; + +// import ReactQuill from 'react-quill'; +// import 'react-quill/dist/quill.snow.css'; + +declare global { + interface Window { + e2eAnki: { + clean(): void; + flipToBack(): void; + render(html: string): void; + }; + } +} + +export const Previewer: FC<{ template: string }> = ({ template }) => { + const { data, loading } = useRequest( + () => + Promise.all([ + fetch(`/builds/${template}/front.html`).then((res) => res.text()), + fetch(`/builds/${template}/back.html`).then((res) => res.text()), + fetch(`/builds/${template}/build.json`).then( + (res) => res.json() as Promise, + ), + ]), + { + refreshDeps: [template], + }, + ); + + if (loading || !data) { + return ; + } + + const [front, back, build] = data; + + return ( + + ); +}; + +const PreviewerFrame: FC<{ build: BuildJson; front: string; back: string }> = ({ + build, + front, + back, +}) => { + const iframe = useRef(null); + const flipCallback = useRef<() => void>(); + const [fields] = useState>( + build.notes[0].fields as Record, + ); + const fieldsInDrawer = useRef(fields); + + useEffect(() => { + const page = iframe.current?.contentWindow; + if (!page) { + return; + } + const render = () => { + page.localStorage.clear(); + page.sessionStorage.clear(); + page.localStorage.setItem(`at:${build.config.entry}:hideAbout`, 'true'); + const renderFields = Object.assign( + {}, + Object.fromEntries( + [...build.fields, ...BUILTIN_FIELDS].map((k) => [k, '']), + ), + build.notes[0].fields, + fields, + ); + const frontHtml = renderTemplate(front, renderFields); + const backHtml = renderTemplate(back, { + ...renderFields, + FrontSide: frontHtml, + }); + page.e2eAnki.render(frontHtml); + page.e2eAnki.flipToBack = () => { + requestAnimationFrame(() => { + page.e2eAnki.render(backHtml); + setTimeout(() => { + page.document + .getElementById('answer') + ?.scrollIntoView({ behavior: 'smooth' }); + }, 500); + }); + }; + flipCallback.current = page.e2eAnki.flipToBack; + }; + const requestRender = () => { + if (page.e2eAnki) { + render(); + } else { + requestAnimationFrame(requestRender); + } + }; + requestAnimationFrame(requestRender); + }, [front, back, fields]); + + const [, setShowFields] = useState(false); + const toggleDrawer = () => setShowFields((prev) => !prev); + return ( +
+