diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 00000000..211e7900
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,65 @@
+# Simple workflow for deploying static content to GitHub Pages
+name: Deploy static content to Pages
+
+on:
+ # Runs on pushes targeting the branch
+ push:
+ branches:
+ - master
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ # Single deploy job since we're just deploying
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.12"]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ ref: master
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Python dependencies
+ run: |
+ python -m pip install . &&
+ python -m pip install -r scripts/requirements.txt
+
+ - name: Build static site contents
+ run: |
+ python scripts/build.py
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: '_public'
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 2456f416..52fc8ce2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ __pycache__/
build/
dist/
*.egg-info/
+_public/
# Compiled dictionary
*.list
diff --git a/README.md b/README.md
index d7c788fe..63a31530 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,14 @@ converter.convert_file(input=None, output=None
*/
```
+## 線上版
+
+[簡繁祕書線上版](https://danny0838.github.io/sts-lib/)
+
+本線上轉換工具支援文字轉換及檔案轉換。前者只要在輸入區填入文字,就會自動轉換並且可以互動式校訂。後者可以用按鈕或拖放選擇一或多個檔案,就會逐一轉換後自動下載。預設檔案輸入輸出編碼皆是UTF-8,如要輸入其他編碼的檔案,可在進階選項設定。
+
+目前內建 [OpenCC](https://github.com/BYVoid/OpenCC) 的轉換方案,並且修正了 OpenCC 演算法缺陷導致一些地區詞無法正常轉換的問題(詳見[相關問題回報](https://github.com/BYVoid/OpenCC/issues/475))。未來有機會再擴充,如中文維基百科的轉換詞庫。
+
## License 許可協議
本專案以 Apache License 2.0 協議授權使用。
diff --git a/scripts/build.py b/scripts/build.py
index e162bc46..dc0743ed 100644
--- a/scripts/build.py
+++ b/scripts/build.py
@@ -41,6 +41,40 @@ def main():
tpl = env.get_template('index_single.html')
render_on_demand(file, tpl, single_page=True)
+ # build static site contents under PUBLIC_DIR
+ www_dir = os.path.join(root_dir, PUBLIC_DIR)
+ os.makedirs(www_dir, exist_ok=True)
+
+ file = os.path.join(www_dir, 'index.html')
+ tpl = env.get_template('index.html')
+ render_on_demand(file, tpl)
+
+ file = os.path.join(www_dir, 'index.css')
+ tpl = env.get_template('index.css')
+ render_on_demand(file, tpl)
+
+ file = os.path.join(www_dir, 'index.js')
+ tpl = env.get_template('index.js')
+ render_on_demand(file, tpl)
+
+ file = os.path.join(www_dir, 'sts.js')
+ tpl = env.get_template('sts.js')
+ render_on_demand(file, tpl)
+
+ # -- compile *.tlist
+ dicts_dir = os.path.join(www_dir, 'dicts', 'opencc')
+ os.makedirs(dicts_dir, exist_ok=True)
+ maker = StsMaker()
+ config_files = os.path.join(glob.escape(StsMaker.config_dir), '[!_]*.json')
+ for config_file in glob.iglob(config_files):
+ file = maker.make(config_file, quiet=True)
+ basename = os.path.basename(file)
+ dest = os.path.join(dicts_dir, basename)
+
+ if not os.path.isfile(dest) or os.path.getmtime(file) > os.path.getmtime(dest):
+ print(f'updating: {dest}')
+ shutil.copyfile(file, dest)
+
if __name__ == '__main__':
main()
diff --git a/sts/data/htmlpage/index.css b/sts/data/htmlpage/index.css
index 9f850102..f54f7c62 100644
--- a/sts/data/htmlpage/index.css
+++ b/sts/data/htmlpage/index.css
@@ -30,3 +30,8 @@ body > footer { margin-top: 1em; text-align: center; font-size: small; }
.popup a { padding: .3em; cursor: pointer; }
.popup a:hover { background-color: #ccc; }
.popup a:not([tabindex="0"])::before { content: attr(tabindex) "."; }
+
+{%- if not single_page %}
+#panel section { margin: .5em auto; }
+#panel textarea { width: 100%; height: 30vh; min-height: 120px; box-sizing: border-box; }
+{%- endif %}
diff --git a/sts/data/htmlpage/index.html b/sts/data/htmlpage/index.html
index 73b5f653..9954612e 100644
--- a/sts/data/htmlpage/index.html
+++ b/sts/data/htmlpage/index.html
@@ -4,16 +4,64 @@
{%- block head %}
-
{% block title %}{% endblock title %}
+{% block title %}簡繁祕書線上版 v2.0.0{% endblock title %}
{%- block styles %}
+
{%- endblock styles %}
{%- block scripts %}
+
+
{%- endblock scripts %}
{%- endblock head %}
{%- block viewer %}
+
{%- endblock viewer %}
+{%- block panel %}
+
+{%- endblock panel %}
{%- block help %}
操作說明
@@ -44,6 +92,8 @@ 操作鍵
{%- endblock help %}
diff --git a/sts/data/htmlpage/index.js b/sts/data/htmlpage/index.js
index 14505550..531fdf26 100644
--- a/sts/data/htmlpage/index.js
+++ b/sts/data/htmlpage/index.js
@@ -624,3 +624,231 @@ document.addEventListener('DOMContentLoaded', (event) => {
const target = viewer.querySelector('a.unchecked');
if (target) { target.focus(); }
});
+
+{%- if not single_page %}
+
+const excludeRegexPattern = /^\/(.*)\/([a-z]*)$/;
+
+function parseExcludePattern(exclude) {
+ if (!exclude) { return null; }
+ const m = excludeRegexPattern.exec(exclude);
+ if (!m) { throw new Error(`invalid regex string for exclude pattern: ${exclude}`); }
+ const source = m[1];
+ let flags = new Set(m[2]);
+ flags.add('g');
+ flags.delete('y');
+ flags = [...flags.values()].join('');
+ return new RegExp(source, flags);
+}
+
+function parseExcludePatternSafely(exclude) {
+ try {
+ return parseExcludePattern(exclude)
+ } catch (ex) {
+ console.error(ex);
+ return null;
+ }
+}
+
+async function loadDict(mode) {
+ const url = `dicts/${mode}.tlist`;
+ return await sts.StsDict.load(url);
+}
+
+async function convertText(text, mode, exclude) {
+ const dict = await loadDict(mode);
+ const timeStart = performance.now();
+ const result = await dict.convertText(text, parseExcludePatternSafely(exclude));
+ console.log(`convert (bytes=${text.length}, mode=${mode}): ${performance.now() - timeStart} ms`);
+ return result;
+}
+
+async function convertHtml(text, mode, exclude) {
+ const dict = await loadDict(mode);
+ const timeStart = performance.now();
+ const html = dict.convertHtml(text, parseExcludePatternSafely(exclude));
+ const wrapper = document.getElementById('viewer');
+ wrapper.innerHTML = html;
+ console.log(`convertHtml (bytes=${text.length}, mode=${mode}): ${performance.now() - timeStart} ms`);
+ wrapper.hidden = false;
+ wrapper.scrollIntoView();
+
+ let a = wrapper.querySelector('a.unchecked');
+ if (a) { a.focus(); return; }
+}
+
+async function convertFile(file, mode, exclude, charset) {
+ const text = await readFileAsText(file, charset);
+ const result = await convertText(text, mode, exclude);
+ const fileNew = new File([result], file.name, {type: 'text/plain'});
+ downloadFile(fileNew);
+}
+
+async function readFileAsText(blob, charset = 'utf-8') {
+ const event = await new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.onload = resolve;
+ reader.onerror = reject;
+ reader.readAsText(blob, charset);
+ });
+ return event.target.result;
+}
+
+function downloadFile(file) {
+ const a = document.createElement('a');
+ a.download = file.name;
+ a.href = URL.createObjectURL(file);
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+}
+
+async function showAdvancedOptions(formElem) {
+ if (typeof HTMLDialogElement === 'undefined') {
+ alert('瀏覽器不支援